From 481e7149548a0fbe42ca7f846cfa56a66dcf4898 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 19 Sep 2025 13:58:21 +0800 Subject: [PATCH 01/27] first commit --- .gitignore | 5 ++++ metal/cli/seo/facotory.go | 19 ++++++++++++++ metal/cli/seo/schema.go | 47 +++++++++++++++++++++++++++++++++ metal/cli/seo/stub.html | 53 ++++++++++++++++++++++++++++++++++++++ storage/seo/.gitkeep | 0 storage/seo/pages/.gitkeep | 0 6 files changed, 124 insertions(+) create mode 100644 metal/cli/seo/facotory.go create mode 100644 metal/cli/seo/schema.go create mode 100644 metal/cli/seo/stub.html create mode 100644 storage/seo/.gitkeep create mode 100644 storage/seo/pages/.gitkeep diff --git a/.gitignore b/.gitignore index dda43df9..a727b153 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ tmp database/infra/data +# -- [SEO]: static files +storage/seo/pages/*.* +!storage/seo/.gitkeep +!storage/seo/pages/.gitkeep + # --- [Caddy]: mtls caddy/mtls/*.* !caddy/mtls/.gitkeep diff --git a/metal/cli/seo/facotory.go b/metal/cli/seo/facotory.go new file mode 100644 index 00000000..dd877ac0 --- /dev/null +++ b/metal/cli/seo/facotory.go @@ -0,0 +1,19 @@ +package seo + +import htmltemplate "html/template" + +type AssetConfig struct { + CanonicalBase string + DefaultLang string + SiteName string +} + +type Generator struct { + OutputDir string + assets AssetConfig + tmpl *htmltemplate.Template +} + +type Handler struct { + Generator *Generator +} diff --git a/metal/cli/seo/schema.go b/metal/cli/seo/schema.go new file mode 100644 index 00000000..49b80798 --- /dev/null +++ b/metal/cli/seo/schema.go @@ -0,0 +1,47 @@ +package seo + +import htmltemplate "html/template" + +type Template struct { + Lang string `validate:"required,oneof=en"` + Title string `validate:"required,min=10"` + Description string `validate:"required,min=10"` + Canonical string `validate:"required,url"` //website url + Robots string `validate:"required"` // default: index,follow + ThemeColor string `validate:"required"` // default: #0E172B -> dark + JsonLD htmltemplate.JS `validate:"required"` + OGTagOg TagOg `validate:"required"` + Twitter Twitter `validate:"required"` + HrefLang []HrefLang `validate:"required"` + Favicons []Favicon `validate:"required"` + Manifest string `validate:"required"` + AppleTouchIcon string `validate:"required"` +} + +type TagOg struct { + Type string `validate:"required,oneof=website"` //website + Image string `validate:"required,url"` //https://oullin.io/assets/about-Dt5rMl63.jpg + ImageAlt string `validate:"required,min=10"` + ImageWidth string `validate:"required"` //600 + ImageHeight string `validate:"required"` //400 + SiteName string `validate:"required,min=5"` + Locale string `validate:"required,min=5"` //en_GB +} + +type Twitter struct { + Card string `validate:"required,oneof=summary_large_image"` + Image string `validate:"required,url"` //https://oullin.io/assets/about-Dt5rMl63.jpg + ImageAlt string `validate:"required,min=10"` +} + +type HrefLang struct { + Lang string `validate:"required,oneof=en"` + Href string `validate:"required,url"` +} + +type Favicon 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/stub.html b/metal/cli/seo/stub.html new file mode 100644 index 00000000..c63c94e3 --- /dev/null +++ b/metal/cli/seo/stub.html @@ -0,0 +1,53 @@ + + + + {{.Title}} + + + + + + + + + + + + {{- range .HrefLang }} + + {{- end }} + + + + + + + + + + + + + + + + + + + + + + + + + {{- range .Favicons }} + + {{- end }} + + + + + + + + diff --git a/storage/seo/.gitkeep b/storage/seo/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/storage/seo/pages/.gitkeep b/storage/seo/pages/.gitkeep new file mode 100644 index 00000000..e69de29b From 7b633c65d747bbdf8d79a5fcfe1aef6d6471c8c6 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 19 Sep 2025 16:09:13 +0800 Subject: [PATCH 02/27] naming + env --- .env.example | 3 ++ .env.gh.example | 5 --- .env.prod.example | 37 --------------------- docker-compose.yml | 5 +-- handler/keep_alive.go | 4 +-- handler/keep_alive_db.go | 4 +-- handler/keep_alive_db_test.go | 2 +- handler/keep_alive_test.go | 2 +- metal/cli/panel/menu.go | 2 ++ metal/cli/seo/schema.go | 7 ++++ metal/env/env.go | 13 ++++---- metal/env/ping.go | 8 ++--- metal/env/seo.go | 5 +++ metal/kernel/factory.go | 39 ++++++++++++++--------- metal/kernel/router_keep_alive_db_test.go | 2 +- metal/kernel/router_keep_alive_test.go | 2 +- metal/makefile/build.mk | 9 +++++- metal/makefile/db.mk | 7 ++-- 18 files changed, 74 insertions(+), 82 deletions(-) delete mode 100644 .env.gh.example delete mode 100644 .env.prod.example create mode 100644 metal/env/seo.go diff --git a/.env.example b/.env.example index 47fbb77e..f070b2c1 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,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/docker-compose.yml b/docker-compose.yml index 5f2ab818..1d8a92ce 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 - image: golang:1.25.1-alpine@sha256:b6ed3fd0452c0e9bcdef5597f29cc1418f61672e9d3a2f55bf02e7222c014abd + env_file: + - ./.env + image: golang:1.25.1-alpine 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/panel/menu.go b/metal/cli/panel/menu.go index 074c8d7f..73759ad3 100644 --- a/metal/cli/panel/menu.go +++ b/metal/cli/panel/menu.go @@ -90,6 +90,8 @@ func (p *Menu) Print() { 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("5) Generate SEO.", inner) + p.PrintOption(" ", inner) p.PrintOption("0) Exit.", inner) diff --git a/metal/cli/seo/schema.go b/metal/cli/seo/schema.go index 49b80798..d5e8bdf2 100644 --- a/metal/cli/seo/schema.go +++ b/metal/cli/seo/schema.go @@ -2,6 +2,13 @@ package seo import htmltemplate "html/template" +type SEO struct { + SpaPublicDir string `validate:"required,dirpath"` + SiteURL string `validate:"required,url"` + Lang string `validate:"required,oneof=en"` + SiteName string `validate:"required,min=10"` +} + type Template struct { Lang string `validate:"required,oneof=en"` Title string `validate:"required,min=10"` diff --git a/metal/env/env.go b/metal/env/env.go index 186cc1d2..1ce2e13b 100644 --- a/metal/env/env.go +++ b/metal/env/env.go @@ -7,12 +7,13 @@ import ( ) type Environment struct { - App AppEnvironment - DB DBEnvironment - Logs LogsEnvironment - Network NetEnvironment - Sentry SentryEnvironment - Ping Ping + App AppEnvironment `validate:"required"` + DB DBEnvironment `validate:"required"` + Logs LogsEnvironment `validate:"required"` + Network NetEnvironment `validate:"required"` + Sentry SentryEnvironment `validate:"required"` + Ping PingEnvironment `validate:"required"` + Seo SeoEnvironment `validate:"required"` } // SecretsDir defines where secret files are read from. It can be overridden in diff --git a/metal/env/ping.go b/metal/env/ping.go index a9c9cd7b..9ce9b0d8 100644 --- a/metal/env/ping.go +++ b/metal/env/ping.go @@ -2,20 +2,20 @@ package env import "strings" -type Ping struct { +type PingEnvironment struct { Username string `validate:"required,min=16"` Password string `validate:"required,min=16"` } -func (p Ping) GetUsername() string { +func (p PingEnvironment) GetUsername() string { return p.Username } -func (p Ping) GetPassword() string { +func (p PingEnvironment) GetPassword() string { return p.Password } -func (p Ping) HasInvalidCreds(username, password string) bool { +func (p PingEnvironment) HasInvalidCreds(username, password string) bool { return username != strings.TrimSpace(p.Username) || password != strings.TrimSpace(p.Password) } diff --git a/metal/env/seo.go b/metal/env/seo.go new file mode 100644 index 00000000..c8b0368e --- /dev/null +++ b/metal/env/seo.go @@ -0,0 +1,5 @@ +package env + +type SeoEnvironment struct { + SpaDir string `validate:"required"` +} diff --git a/metal/kernel/factory.go b/metal/kernel/factory.go index 9bd7e056..cac051d3 100644 --- a/metal/kernel/factory.go +++ b/metal/kernel/factory.go @@ -36,7 +36,7 @@ func MakeDbConnection(env *env.Environment) *database.Connection { dbConn, err := database.MakeConnection(env) if err != nil { - panic("Sql: error connecting to PostgreSQL: " + err.Error()) + panic("Sql: error connecting to PostgresSQL: " + err.Error()) } return dbConn @@ -77,29 +77,33 @@ func MakeEnv(validate *portal.Validator) *env.Environment { TimeZone: env.GetEnvVar("ENV_DB_TIMEZONE"), } - logsCreds := env.LogsEnvironment{ + logsEnv := env.LogsEnvironment{ Level: env.GetEnvVar("ENV_APP_LOG_LEVEL"), Dir: env.GetEnvVar("ENV_APP_LOGS_DIR"), DateFormat: env.GetEnvVar("ENV_APP_LOGS_DATE_FORMAT"), } - net := env.NetEnvironment{ + netEnv := env.NetEnvironment{ HttpHost: env.GetEnvVar("ENV_HTTP_HOST"), HttpPort: env.GetEnvVar("ENV_HTTP_PORT"), PublicAllowedIP: env.GetEnvVar("ENV_PUBLIC_ALLOWED_IP"), IsProduction: app.IsProduction(), // --- only needed for validation purposes } - sentryEnvironment := env.SentryEnvironment{ + sentryEnv := env.SentryEnvironment{ DSN: env.GetEnvVar("ENV_SENTRY_DSN"), CSP: env.GetEnvVar("ENV_SENTRY_CSP"), } - pingEnvironment := env.Ping{ + pingEnv := env.PingEnvironment{ Username: env.GetEnvVar("ENV_PING_USERNAME"), Password: env.GetEnvVar("ENV_PING_PASSWORD"), } + seoEnv := env.SeoEnvironment{ + SpaDir: env.GetEnvVar("ENV_SPA_DIR"), + } + if _, err := validate.Rejects(app); err != nil { panic(errorSuffix + "invalid [APP] model: " + validate.GetErrorsAsJson()) } @@ -108,33 +112,38 @@ func MakeEnv(validate *portal.Validator) *env.Environment { panic(errorSuffix + "invalid [Sql] model: " + validate.GetErrorsAsJson()) } - if _, err := validate.Rejects(logsCreds); err != nil { - panic(errorSuffix + "invalid [logs Creds] model: " + validate.GetErrorsAsJson()) + if _, err := validate.Rejects(logsEnv); err != nil { + panic(errorSuffix + "invalid [logs Credentials] model: " + validate.GetErrorsAsJson()) } - if _, err := validate.Rejects(net); err != nil { + if _, err := validate.Rejects(netEnv); err != nil { panic(errorSuffix + "invalid [NETWORK] model: " + validate.GetErrorsAsJson()) } - if _, err := validate.Rejects(sentryEnvironment); err != nil { + if _, err := validate.Rejects(sentryEnv); err != nil { panic(errorSuffix + "invalid [SENTRY] model: " + validate.GetErrorsAsJson()) } - if _, err := validate.Rejects(pingEnvironment); err != nil { + if _, err := validate.Rejects(pingEnv); err != nil { panic(errorSuffix + "invalid [ping] model: " + validate.GetErrorsAsJson()) } + if _, err := validate.Rejects(seoEnv); err != nil { + panic(errorSuffix + "invalid [seo] model: " + validate.GetErrorsAsJson()) + } + blog := &env.Environment{ App: app, DB: db, - Logs: logsCreds, - Network: net, - Sentry: sentryEnvironment, - Ping: pingEnvironment, + Logs: logsEnv, + Network: netEnv, + Sentry: sentryEnv, + Ping: pingEnv, + Seo: seoEnv, } if _, err := validate.Rejects(blog); err != nil { - panic(errorSuffix + "invalid blog [ENVIRONMENT] model: " + validate.GetErrorsAsJson()) + panic(errorSuffix + "invalid [oullin] model: " + validate.GetErrorsAsJson()) } return blog diff --git a/metal/kernel/router_keep_alive_db_test.go b/metal/kernel/router_keep_alive_db_test.go index baac81b6..3d36de71 100644 --- a/metal/kernel/router_keep_alive_db_test.go +++ b/metal/kernel/router_keep_alive_db_test.go @@ -13,7 +13,7 @@ import ( func TestKeepAliveDBRoute(t *testing.T) { db, _ := handlertests.MakeTestDB(t) r := Router{ - Env: &env.Environment{Ping: env.Ping{Username: "user", Password: "pass"}}, + Env: &env.Environment{Ping: env.PingEnvironment{Username: "user", Password: "pass"}}, Db: db, Mux: http.NewServeMux(), Pipeline: middleware.Pipeline{PublicMiddleware: middleware.MakePublicMiddleware("", false)}, diff --git a/metal/kernel/router_keep_alive_test.go b/metal/kernel/router_keep_alive_test.go index bb00b728..f604d4c3 100644 --- a/metal/kernel/router_keep_alive_test.go +++ b/metal/kernel/router_keep_alive_test.go @@ -11,7 +11,7 @@ import ( func TestKeepAliveRoute(t *testing.T) { r := Router{ - Env: &env.Environment{Ping: env.Ping{Username: "user", Password: "pass"}}, + Env: &env.Environment{Ping: env.PingEnvironment{Username: "user", Password: "pass"}}, Mux: http.NewServeMux(), Pipeline: middleware.Pipeline{PublicMiddleware: middleware.MakePublicMiddleware("", false)}, } diff --git a/metal/makefile/build.mk b/metal/makefile/build.mk index e76d45e1..bb4c9dc0 100644 --- a/metal/makefile/build.mk +++ b/metal/makefile/build.mk @@ -1,10 +1,17 @@ -.PHONY: build-local build-ci build-prod build-release build-deploy build-local-restart build-prod-force +.PHONY: build-local build-ci build-prod build-release build-deploy build-local-restart build-prod-force build-fresh BUILD_VERSION ?= latest BUILD_PACKAGE_OWNER := oullin DB_INFRA_ROOT_PATH ?= $(ROOT_PATH)/database/infra DB_INFRA_SCRIPTS_PATH ?= $(DB_INFRA_ROOT_PATH)/scripts + +build-fresh: + make fresh && \ + make db:fresh && \ + make db:migrate && \ + make db:seed + build-local: docker compose --profile local up --build -d diff --git a/metal/makefile/db.mk b/metal/makefile/db.mk index e18f97f0..1f1ad41a 100644 --- a/metal/makefile/db.mk +++ b/metal/makefile/db.mk @@ -15,9 +15,7 @@ DB_INFRA_SCRIPTS_PATH ?= $(DB_INFRA_ROOT_PATH)/scripts # --- Migrations DB_MIGRATE_DOCKER_ENV_FLAGS = -e ENV_DB_HOST=$(DB_DOCKER_SERVICE_NAME) \ - -e ENV_DB_SSL_MODE=require \ - -e ENV_PING_USERNAME=0101010101010101 \ - -e ENV_PING_PASSWORD=0101010101010101 + -e ENV_DB_SSL_MODE=require # --- SSL Certificate Files DB_INFRA_SERVER_CRT := $(DB_INFRA_SSL_PATH)/server.crt @@ -58,7 +56,8 @@ db\:secure: openssl x509 -req -days 365 -in $(DB_INFRA_SERVER_CSR) -signkey $(DB_INFRA_SERVER_KEY) -out $(DB_INFRA_SERVER_CRT) db\:seed: - docker compose run --rm $(DB_MIGRATE_DOCKER_ENV_FLAGS) $(DB_API_RUNNER_SERVICE) go run ./database/seeder/main.go + docker compose --env-file ./.env run --rm $(DB_MIGRATE_DOCKER_ENV_FLAGS) $(DB_API_RUNNER_SERVICE) \ + go run ./database/seeder/main.go # -------------------------------------------------------------------------------------------------------------------- # # --- Migrations From 9d4c6f83d4d8e9b71ec10b8f6a62511a2bae2781 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 19 Sep 2025 16:19:39 +0800 Subject: [PATCH 03/27] dev/ux --- Makefile | 10 ++++++---- metal/makefile/app.mk | 7 +++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 352473bf..4b7ae8a0 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)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-cli-local$(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/metal/makefile/app.mk b/metal/makefile/app.mk index 2c0cd4f7..2ddb0fff 100644 --- a/metal/makefile/app.mk +++ b/metal/makefile/app.mk @@ -1,4 +1,4 @@ -.PHONY: fresh destroy audit watch format run-cli test-all run-cli-local +.PHONY: fresh destroy audit watch format run-cli test-all run-cli-docker run-cli-local format: gofmt -w -s . @@ -53,8 +53,11 @@ run-cli: DB_SECRET_DBNAME="$(DB_SECRET_DBNAME)" \ docker compose run --rm api-runner go run ./metal/cli/main.go -run-cli-local: +run-cli-docker: make run-cli DB_SECRET_USERNAME=./database/infra/secrets/pg_username DB_SECRET_PASSWORD=./database/infra/secrets/pg_password DB_SECRET_DBNAME=./database/infra/secrets/pg_dbname test-all: go test ./... + +run-cli-local: + go run metal/cli/main.go From 190764c918504964e03c482a3d8041ef0313b39f Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 19 Sep 2025 16:46:13 +0800 Subject: [PATCH 04/27] organise menu --- Makefile | 2 +- metal/cli/accounts/handler.go | 38 +++++++++++------------------------ metal/cli/main.go | 26 +++++++----------------- metal/cli/panel/menu.go | 3 +-- metal/makefile/app.mk | 4 ++-- 5 files changed, 23 insertions(+), 50 deletions(-) diff --git a/Makefile b/Makefile index 4b7ae8a0..c3bfb0e0 100644 --- a/Makefile +++ b/Makefile @@ -57,7 +57,7 @@ help: @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-cli-local$(NC) : Run the application 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" diff --git a/metal/cli/accounts/handler.go b/metal/cli/accounts/handler.go index fa345d01..586d4568 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 h.print(token, item) != 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/main.go b/metal/cli/main.go index c841cef2..cd112cad 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -57,7 +57,7 @@ func main() { return case 4: - if err = generateApiAccountsHTTPSignature(menu); err != nil { + if err = generateSEO(menu); err != nil { cli.Errorln(err.Error()) continue } @@ -110,6 +110,10 @@ func createNewApiAccount(menu panel.Menu) error { return err } + if err = showApiAccount(menu); err != nil { + return err + } + return nil } @@ -126,29 +130,13 @@ 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 - - if account, err = menu.CaptureAccountName(); err != nil { - return err - } - - if handler, err = accounts.MakeHandler(dbConn, environment); err != nil { - return err - } - - if err = handler.CreateSignature(account); err != nil { - return err - } - +func generateSEO(menu panel.Menu) error { return nil } diff --git a/metal/cli/panel/menu.go b/metal/cli/panel/menu.go index 73759ad3..a62ff4dd 100644 --- a/metal/cli/panel/menu.go +++ b/metal/cli/panel/menu.go @@ -89,8 +89,7 @@ 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("5) Generate SEO.", inner) + p.PrintOption("4) Generate SEO.", inner) p.PrintOption(" ", inner) p.PrintOption("0) Exit.", inner) diff --git a/metal/makefile/app.mk b/metal/makefile/app.mk index 2ddb0fff..87b03688 100644 --- a/metal/makefile/app.mk +++ b/metal/makefile/app.mk @@ -1,4 +1,4 @@ -.PHONY: fresh destroy audit watch format run-cli test-all run-cli-docker run-cli-local +.PHONY: fresh destroy audit watch format run-cli test-all run-cli-docker run-metal format: gofmt -w -s . @@ -59,5 +59,5 @@ run-cli-docker: test-all: go test ./... -run-cli-local: +run-metal: go run metal/cli/main.go From dc8b80ca83e8184aca4e42ef3955f262594c4857 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 19 Sep 2025 17:14:09 +0800 Subject: [PATCH 05/27] start working on generator --- .env.example | 1 + metal/cli/seo/{schema.go => data.go} | 18 ++++----- metal/cli/seo/facotory.go | 19 ---------- metal/cli/seo/generator.go | 57 ++++++++++++++++++++++++++++ metal/env/app.go | 7 ++++ metal/kernel/factory.go | 1 + 6 files changed, 75 insertions(+), 28 deletions(-) rename metal/cli/seo/{schema.go => data.go} (83%) delete mode 100644 metal/cli/seo/facotory.go create mode 100644 metal/cli/seo/generator.go diff --git a/.env.example b/.env.example index f070b2c1..1e6230a9 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ 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_URL= # --- The App master key for encryption. ENV_APP_MASTER_KEY= diff --git a/metal/cli/seo/schema.go b/metal/cli/seo/data.go similarity index 83% rename from metal/cli/seo/schema.go rename to metal/cli/seo/data.go index d5e8bdf2..52089ea7 100644 --- a/metal/cli/seo/schema.go +++ b/metal/cli/seo/data.go @@ -9,7 +9,7 @@ type SEO struct { SiteName string `validate:"required,min=10"` } -type Template struct { +type TemplateData struct { Lang string `validate:"required,oneof=en"` Title string `validate:"required,min=10"` Description string `validate:"required,min=10"` @@ -17,15 +17,15 @@ type Template struct { Robots string `validate:"required"` // default: index,follow ThemeColor string `validate:"required"` // default: #0E172B -> dark JsonLD htmltemplate.JS `validate:"required"` - OGTagOg TagOg `validate:"required"` - Twitter Twitter `validate:"required"` - HrefLang []HrefLang `validate:"required"` - Favicons []Favicon `validate:"required"` + OGTagOg TagOgData `validate:"required"` + Twitter TwitterData `validate:"required"` + HrefLang []HrefLangData `validate:"required"` + Favicons []FaviconData `validate:"required"` Manifest string `validate:"required"` AppleTouchIcon string `validate:"required"` } -type TagOg struct { +type TagOgData struct { Type string `validate:"required,oneof=website"` //website Image string `validate:"required,url"` //https://oullin.io/assets/about-Dt5rMl63.jpg ImageAlt string `validate:"required,min=10"` @@ -35,18 +35,18 @@ type TagOg struct { Locale string `validate:"required,min=5"` //en_GB } -type Twitter struct { +type TwitterData struct { Card string `validate:"required,oneof=summary_large_image"` Image string `validate:"required,url"` //https://oullin.io/assets/about-Dt5rMl63.jpg ImageAlt string `validate:"required,min=10"` } -type HrefLang struct { +type HrefLangData struct { Lang string `validate:"required,oneof=en"` Href string `validate:"required,url"` } -type Favicon struct { +type FaviconData struct { Rel string `validate:"required,oneof=icon"` Href string `validate:"required,url"` Type string `validate:"required"` diff --git a/metal/cli/seo/facotory.go b/metal/cli/seo/facotory.go deleted file mode 100644 index dd877ac0..00000000 --- a/metal/cli/seo/facotory.go +++ /dev/null @@ -1,19 +0,0 @@ -package seo - -import htmltemplate "html/template" - -type AssetConfig struct { - CanonicalBase string - DefaultLang string - SiteName string -} - -type Generator struct { - OutputDir string - assets AssetConfig - tmpl *htmltemplate.Template -} - -type Handler struct { - Generator *Generator -} diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go new file mode 100644 index 00000000..edf1e28f --- /dev/null +++ b/metal/cli/seo/generator.go @@ -0,0 +1,57 @@ +package seo + +import ( + "embed" + "fmt" + htmltemplate "html/template" + + "github.com/oullin/database" + "github.com/oullin/metal/env" +) + +//go:embed stub.html +var templatesFS embed.FS + +type Template struct { + StubPath string + OutputDir string + Lang string + SiteName string + SiteURL string +} + +type Generator struct { + Tmpl Template + Env *env.Environment + DB *database.Connection +} + +func NewGenerator(db *database.Connection, env *env.Environment) (*Generator, error) { + tmpl := Template{ + StubPath: "stub.html", + OutputDir: env.Seo.SpaDir, + Lang: env.App.Lang(), + SiteName: env.App.Name, + SiteURL: env.App.URL, + } + + return &Generator{ + Tmpl: tmpl, + Env: env, + DB: db, + }, nil +} + +func (t *Template) LoadTemplate() (*htmltemplate.Template, error) { + raw, err := templatesFS.ReadFile(t.StubPath) + if err != nil { + return nil, fmt.Errorf("reading template: %w", err) + } + + tmpl, err := htmltemplate.New("public").Parse(string(raw)) + if err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + + return tmpl, nil +} diff --git a/metal/env/app.go b/metal/env/app.go index 94e8582d..1798ae5e 100644 --- a/metal/env/app.go +++ b/metal/env/app.go @@ -4,8 +4,11 @@ const local = "local" const staging = "staging" const production = "production" +const defaultLanguage = "en" + type AppEnvironment struct { Name string `validate:"required,min=4"` + URL string `validate:"required,url"` Type string `validate:"required,lowercase,oneof=local production staging"` MasterKey string `validate:"required,min=32"` } @@ -21,3 +24,7 @@ func (e AppEnvironment) IsStaging() bool { func (e AppEnvironment) IsLocal() bool { return e.Type == local } + +func (e AppEnvironment) Lang() string { + return defaultLanguage +} diff --git a/metal/kernel/factory.go b/metal/kernel/factory.go index cac051d3..a833d33b 100644 --- a/metal/kernel/factory.go +++ b/metal/kernel/factory.go @@ -62,6 +62,7 @@ func MakeEnv(validate *portal.Validator) *env.Environment { app := env.AppEnvironment{ Name: env.GetEnvVar("ENV_APP_NAME"), + URL: env.GetEnvVar("ENV_APP_URL"), Type: env.GetEnvVar("ENV_APP_ENV_TYPE"), MasterKey: env.GetEnvVar("ENV_APP_MASTER_KEY"), } From e711fb277b9d74640593341bcbcc0a2532370196 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 20 Sep 2025 17:03:35 +0800 Subject: [PATCH 06/27] start working on mapping --- metal/kernel/app.go | 70 +++++++++++++++++++++++--------------- metal/kernel/router.go | 66 ++++++++++++++++++++++++++--------- metal/kernel/web/page.go | 69 +++++++++++++++++++++++++++++++++++++ pkg/middleware/pipeline.go | 2 +- 4 files changed, 163 insertions(+), 44 deletions(-) create mode 100644 metal/kernel/web/page.go diff --git a/metal/kernel/app.go b/metal/kernel/app.go index e110064b..51096655 100644 --- a/metal/kernel/app.go +++ b/metal/kernel/app.go @@ -7,6 +7,7 @@ import ( "github.com/oullin/database" "github.com/oullin/database/repository" "github.com/oullin/metal/env" + "github.com/oullin/metal/kernel/web" "github.com/oullin/pkg/auth" "github.com/oullin/pkg/llogs" "github.com/oullin/pkg/middleware" @@ -23,48 +24,63 @@ type App struct { } func MakeApp(e *env.Environment, validator *portal.Validator) (*App, error) { + app := App{ + env: e, + validator: validator, + logs: MakeLogs(e), + sentry: MakeSentry(e), + db: MakeDbConnection(e), + } + + if router, err := app.NewRouter(); err != nil { + return nil, err + } else { + app.SetRouter(*router) + } + + return &app, nil +} + +func (a *App) NewRouter() (*Router, error) { + if a == nil { + return nil, fmt.Errorf("kernel error > router: app is nil") + } + + envi := a.env + tokenHandler, err := auth.MakeTokensHandler( - []byte(e.App.MasterKey), + []byte(envi.App.MasterKey), ) if err != nil { - return nil, fmt.Errorf("bootstrapping error > could not create a token handler: %w", err) + return nil, fmt.Errorf("kernel error > router: could not create a token handler: %w", err) } - db := MakeDbConnection(e) - - app := App{ - env: e, - validator: validator, - logs: MakeLogs(e), - sentry: MakeSentry(e), - db: db, + pipe := middleware.Pipeline{ + Env: envi, + TokenHandler: tokenHandler, + ApiKeys: &repository.ApiKeys{DB: a.db}, + PublicMiddleware: middleware.MakePublicMiddleware( + envi.Network.PublicAllowedIP, + envi.Network.IsProduction, + ), } router := Router{ - Env: e, - Db: db, - Mux: baseHttp.NewServeMux(), - validator: validator, - Pipeline: middleware.Pipeline{ - Env: e, - ApiKeys: &repository.ApiKeys{DB: db}, - TokenHandler: tokenHandler, - PublicMiddleware: middleware.MakePublicMiddleware( - e.Network.PublicAllowedIP, - e.Network.IsProduction, - ), - }, + Env: envi, + Db: a.db, + Pipeline: pipe, + validator: a.validator, + WebsiteRoutes: web.NewRoutes(envi), + Mux: baseHttp.NewServeMux(), } - app.SetRouter(router) - - return &app, nil + return &router, nil } func (a *App) Boot() { if a == nil || a.router == nil { - panic("bootstrapping error > Invalid setup") + panic("kernel error > boot: Invalid setup") } router := *a.router diff --git a/metal/kernel/router.go b/metal/kernel/router.go index 0760117a..8543d4a3 100644 --- a/metal/kernel/router.go +++ b/metal/kernel/router.go @@ -2,32 +2,46 @@ package kernel import ( baseHttp "net/http" + "strings" "github.com/oullin/database" "github.com/oullin/database/repository" "github.com/oullin/handler" "github.com/oullin/metal/env" + "github.com/oullin/metal/kernel/web" "github.com/oullin/pkg/http" "github.com/oullin/pkg/middleware" "github.com/oullin/pkg/portal" ) -type StaticRouteResource interface { - Handle(baseHttp.ResponseWriter, *baseHttp.Request) *http.ApiError -} +const StaticRouteTalks = "talks" +const StaticRouteSocial = "social" +const StaticRouteProfile = "profile" +const StaticRouteProjects = "projects" +const StaticRouteEducation = "education" +const StaticRouteExperience = "experience" +const StaticRouteRecommendations = "recommendations" -func addStaticRoute[H StaticRouteResource](r *Router, path, file string, maker func(string) H) { +func addStaticRoute[H web.StaticRouteResource](r *Router, path, file string, maker func(string) H) { abstract := maker(file) resolver := r.PipelineFor(abstract.Handle) + + r.WebsiteRoutes.AddPageFrom(path, file, func(file string) web.StaticRouteResource { + return maker(file) + }) + r.Mux.HandleFunc("GET "+path, resolver) } type Router struct { - Env *env.Environment - Mux *baseHttp.ServeMux - Pipeline middleware.Pipeline - Db *database.Connection - validator *portal.Validator + //@todo + // --- make these fields required and use the validator to verify them. + Env *env.Environment + Mux *baseHttp.ServeMux + Pipeline middleware.Pipeline + Db *database.Connection + validator *portal.Validator + WebsiteRoutes *web.Routes } func (r *Router) PublicPipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc { @@ -101,29 +115,49 @@ func (r *Router) KeepAliveDB() { } func (r *Router) Profile() { - addStaticRoute(r, "/profile", "./storage/fixture/profile.json", handler.MakeProfileHandler) + path, file := r.StaticRouteFor(StaticRouteProfile) + addStaticRoute(r, path, file, handler.MakeProfileHandler) } func (r *Router) Experience() { - addStaticRoute(r, "/experience", "./storage/fixture/experience.json", handler.MakeExperienceHandler) + path, file := r.StaticRouteFor(StaticRouteExperience) + addStaticRoute(r, path, file, handler.MakeExperienceHandler) } func (r *Router) Projects() { - addStaticRoute(r, "/projects", "./storage/fixture/projects.json", handler.MakeProjectsHandler) + path, file := r.StaticRouteFor(StaticRouteProjects) + addStaticRoute(r, path, file, handler.MakeProjectsHandler) } func (r *Router) Social() { - addStaticRoute(r, "/social", "./storage/fixture/social.json", handler.MakeSocialHandler) + path, file := r.StaticRouteFor(StaticRouteSocial) + addStaticRoute(r, path, file, handler.MakeSocialHandler) } func (r *Router) Talks() { - addStaticRoute(r, "/talks", "./storage/fixture/talks.json", handler.MakeTalksHandler) + path, file := r.StaticRouteFor(StaticRouteTalks) + addStaticRoute(r, path, file, handler.MakeTalksHandler) } func (r *Router) Education() { - addStaticRoute(r, "/education", "./storage/fixture/education.json", handler.MakeEducationHandler) + path, file := r.StaticRouteFor(StaticRouteEducation) + addStaticRoute(r, path, file, handler.MakeEducationHandler) } func (r *Router) Recommendations() { - addStaticRoute(r, "/recommendations", "./storage/fixture/recommendations.json", handler.MakeRecommendationsHandler) + maker := handler.MakeRecommendationsHandler + path, file := r.StaticRouteFor(StaticRouteRecommendations) + + r.WebsiteRoutes.AddPageFrom(path, file, func(file string) web.StaticRouteResource { + return maker(file) + }) + + addStaticRoute(r, path, file, maker) +} + +func (r *Router) StaticRouteFor(slug string) (path string, file string) { + filepath := "/" + strings.Trim(slug, "/") + fixture := "./storage/fixture/" + slug + ".json" + + return filepath, fixture } diff --git a/metal/kernel/web/page.go b/metal/kernel/web/page.go new file mode 100644 index 00000000..dec6a432 --- /dev/null +++ b/metal/kernel/web/page.go @@ -0,0 +1,69 @@ +package web + +import ( + baseHttp "net/http" + + "github.com/oullin/metal/env" + "github.com/oullin/pkg/http" +) + +const HomePage = "/" +const AboutPage = "about" +const ResumePage = "resume" +const ProjectsPage = "projects" + +type StaticRouteResource interface { + Handle(baseHttp.ResponseWriter, *baseHttp.Request) *http.ApiError +} + +type ApiResource struct { + Path string + File string + Maker func(string) StaticRouteResource +} + +type Page struct { + Path string + File string + ApiResource map[string]ApiResource +} + +type Routes struct { + OutputDir string + Lang string + SiteName string + SiteURL string + Pages map[string]Page +} + +func NewRoutes(e *env.Environment) *Routes { + return &Routes{ + SiteURL: e.App.URL, + SiteName: e.App.Name, + Lang: e.App.Lang(), + OutputDir: e.Seo.SpaDir, + Pages: make(map[string]Page), + } +} + +func (r *Routes) AddPageFrom(path, file string, abstract func(string) StaticRouteResource) { + resource := make(map[string]ApiResource, 1) + + resource[path] = ApiResource{ + Path: path, + File: file, + Maker: abstract, + } + + page := Page{ + Path: path, + File: file, + ApiResource: resource, + } + + r.MapResource(page, resource) +} + +func (r *Routes) MapResource(page Page, item map[string]ApiResource) { + //WIP +} diff --git a/pkg/middleware/pipeline.go b/pkg/middleware/pipeline.go index ac53b585..080aa6a3 100644 --- a/pkg/middleware/pipeline.go +++ b/pkg/middleware/pipeline.go @@ -10,7 +10,7 @@ import ( type Pipeline struct { Env *env.Environment ApiKeys *repository.ApiKeys - TokenHandler *auth.TokenHandler + TokenHandler *auth.TokenHandler //@todo Remove! PublicMiddleware PublicMiddleware } From 564cd2e2fd97e4ecc2c2172dafddadb5bd581038 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 22 Sep 2025 12:21:54 +0800 Subject: [PATCH 07/27] extract pkg --- metal/kernel/app.go | 46 +++++++-------- metal/kernel/helpers.go | 3 +- metal/kernel/kernel_test.go | 5 +- metal/{kernel/router.go => router/factory.go} | 37 +++++------- metal/router/fixture.go | 57 +++++++++++++++++++ .../router_keep_alive_db_test.go | 2 +- .../router_keep_alive_test.go | 2 +- .../router_signature_test.go | 2 +- metal/{kernel/web/page.go => router/web.go} | 33 +++++++---- 9 files changed, 124 insertions(+), 63 deletions(-) rename metal/{kernel/router.go => router/factory.go} (73%) create mode 100644 metal/router/fixture.go rename metal/{kernel => router}/router_keep_alive_db_test.go (98%) rename metal/{kernel => router}/router_keep_alive_test.go (98%) rename metal/{kernel => router}/router_signature_test.go (98%) rename metal/{kernel/web/page.go => router/web.go} (55%) diff --git a/metal/kernel/app.go b/metal/kernel/app.go index 51096655..4629210f 100644 --- a/metal/kernel/app.go +++ b/metal/kernel/app.go @@ -7,7 +7,7 @@ import ( "github.com/oullin/database" "github.com/oullin/database/repository" "github.com/oullin/metal/env" - "github.com/oullin/metal/kernel/web" + "github.com/oullin/metal/router" "github.com/oullin/pkg/auth" "github.com/oullin/pkg/llogs" "github.com/oullin/pkg/middleware" @@ -15,7 +15,7 @@ import ( ) type App struct { - router *Router + router *router.Router sentry *portal.Sentry logs llogs.Driver validator *portal.Validator @@ -32,16 +32,16 @@ func MakeApp(e *env.Environment, validator *portal.Validator) (*App, error) { db: MakeDbConnection(e), } - if router, err := app.NewRouter(); err != nil { + if modem, err := app.NewRouter(); err != nil { return nil, err } else { - app.SetRouter(*router) + app.SetRouter(*modem) } return &app, nil } -func (a *App) NewRouter() (*Router, error) { +func (a *App) NewRouter() (*router.Router, error) { if a == nil { return nil, fmt.Errorf("kernel error > router: app is nil") } @@ -66,16 +66,16 @@ func (a *App) NewRouter() (*Router, error) { ), } - router := Router{ + modem := router.Router{ Env: envi, Db: a.db, Pipeline: pipe, - validator: a.validator, - WebsiteRoutes: web.NewRoutes(envi), + Validator: a.validator, Mux: baseHttp.NewServeMux(), + WebsiteRoutes: router.NewWebsiteRoutes(envi), } - return &router, nil + return &modem, nil } func (a *App) Boot() { @@ -83,18 +83,18 @@ func (a *App) Boot() { panic("kernel error > boot: Invalid setup") } - router := *a.router - - router.KeepAlive() - router.KeepAliveDB() - router.Profile() - router.Experience() - router.Projects() - router.Social() - router.Talks() - router.Education() - router.Recommendations() - router.Posts() - router.Categories() - router.Signature() + modem := *a.router + + modem.KeepAlive() + modem.KeepAliveDB() + modem.Profile() + modem.Experience() + modem.Projects() + modem.Social() + modem.Talks() + modem.Education() + modem.Recommendations() + modem.Posts() + modem.Categories() + modem.Signature() } diff --git a/metal/kernel/helpers.go b/metal/kernel/helpers.go index a5bc1565..401db3ca 100644 --- a/metal/kernel/helpers.go +++ b/metal/kernel/helpers.go @@ -5,9 +5,10 @@ import ( "github.com/oullin/database" "github.com/oullin/metal/env" + "github.com/oullin/metal/router" ) -func (a *App) SetRouter(router Router) { +func (a *App) SetRouter(router router.Router) { a.router = &router } diff --git a/metal/kernel/kernel_test.go b/metal/kernel/kernel_test.go index 24b6bc30..8c1ea7b1 100644 --- a/metal/kernel/kernel_test.go +++ b/metal/kernel/kernel_test.go @@ -9,6 +9,7 @@ import ( "github.com/oullin/database" "github.com/oullin/database/repository" + "github.com/oullin/metal/router" "github.com/oullin/pkg/auth" "github.com/oullin/pkg/llogs" "github.com/oullin/pkg/middleware" @@ -119,7 +120,7 @@ func TestAppHelpers(t *testing.T) { app := &App{} mux := http.NewServeMux() - r := Router{Mux: mux, Pipeline: middleware.Pipeline{PublicMiddleware: middleware.MakePublicMiddleware("", false)}} + r := router.Router{Mux: mux, Pipeline: middleware.Pipeline{PublicMiddleware: middleware.MakePublicMiddleware("", false)}} app.SetRouter(r) @@ -156,7 +157,7 @@ func TestAppBootRoutes(t *testing.T) { t.Fatalf("handler err: %v", err) } - router := Router{ + router := router.Router{ Env: env, Mux: http.NewServeMux(), Pipeline: middleware.Pipeline{ diff --git a/metal/kernel/router.go b/metal/router/factory.go similarity index 73% rename from metal/kernel/router.go rename to metal/router/factory.go index 8543d4a3..a721dc95 100644 --- a/metal/kernel/router.go +++ b/metal/router/factory.go @@ -1,4 +1,4 @@ -package kernel +package router import ( baseHttp "net/http" @@ -8,25 +8,16 @@ import ( "github.com/oullin/database/repository" "github.com/oullin/handler" "github.com/oullin/metal/env" - "github.com/oullin/metal/kernel/web" "github.com/oullin/pkg/http" "github.com/oullin/pkg/middleware" "github.com/oullin/pkg/portal" ) -const StaticRouteTalks = "talks" -const StaticRouteSocial = "social" -const StaticRouteProfile = "profile" -const StaticRouteProjects = "projects" -const StaticRouteEducation = "education" -const StaticRouteExperience = "experience" -const StaticRouteRecommendations = "recommendations" - -func addStaticRoute[H web.StaticRouteResource](r *Router, path, file string, maker func(string) H) { +func addStaticRoute[H StaticRouteResource](r *Router, path, file string, maker func(string) H) { abstract := maker(file) resolver := r.PipelineFor(abstract.Handle) - r.WebsiteRoutes.AddPageFrom(path, file, func(file string) web.StaticRouteResource { + r.WebsiteRoutes.AddPageFrom(path, file, func(file string) StaticRouteResource { return maker(file) }) @@ -40,8 +31,8 @@ type Router struct { Mux *baseHttp.ServeMux Pipeline middleware.Pipeline Db *database.Connection - validator *portal.Validator - WebsiteRoutes *web.Routes + Validator *portal.Validator + WebsiteRoutes *WebsiteRoutes } func (r *Router) PublicPipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc { @@ -88,7 +79,7 @@ func (r *Router) Categories() { } func (r *Router) Signature() { - abstract := handler.MakeSignaturesHandler(r.validator, r.Pipeline.ApiKeys) + abstract := handler.MakeSignaturesHandler(r.Validator, r.Pipeline.ApiKeys) generate := r.PublicPipelineFor(abstract.Generate) r.Mux.HandleFunc("POST /generate-signature", generate) @@ -115,40 +106,40 @@ func (r *Router) KeepAliveDB() { } func (r *Router) Profile() { - path, file := r.StaticRouteFor(StaticRouteProfile) + path, file := r.StaticRouteFor(fixtureProfile) addStaticRoute(r, path, file, handler.MakeProfileHandler) } func (r *Router) Experience() { - path, file := r.StaticRouteFor(StaticRouteExperience) + path, file := r.StaticRouteFor(fixtureExperience) addStaticRoute(r, path, file, handler.MakeExperienceHandler) } func (r *Router) Projects() { - path, file := r.StaticRouteFor(StaticRouteProjects) + path, file := r.StaticRouteFor(fixtureProjects) addStaticRoute(r, path, file, handler.MakeProjectsHandler) } func (r *Router) Social() { - path, file := r.StaticRouteFor(StaticRouteSocial) + path, file := r.StaticRouteFor(fixtureSocial) addStaticRoute(r, path, file, handler.MakeSocialHandler) } func (r *Router) Talks() { - path, file := r.StaticRouteFor(StaticRouteTalks) + path, file := r.StaticRouteFor(fixtureTalks) addStaticRoute(r, path, file, handler.MakeTalksHandler) } func (r *Router) Education() { - path, file := r.StaticRouteFor(StaticRouteEducation) + path, file := r.StaticRouteFor(fixtureEducation) addStaticRoute(r, path, file, handler.MakeEducationHandler) } func (r *Router) Recommendations() { maker := handler.MakeRecommendationsHandler - path, file := r.StaticRouteFor(StaticRouteRecommendations) + path, file := r.StaticRouteFor(fixtureRecommendations) - r.WebsiteRoutes.AddPageFrom(path, file, func(file string) web.StaticRouteResource { + r.WebsiteRoutes.AddPageFrom(path, file, func(file string) StaticRouteResource { return maker(file) }) diff --git a/metal/router/fixture.go b/metal/router/fixture.go new file mode 100644 index 00000000..d5795047 --- /dev/null +++ b/metal/router/fixture.go @@ -0,0 +1,57 @@ +package router + +import ( + "fmt" +) + +const fixtureTalks = "talks" +const fixtureSocial = "social" +const fixtureProfile = "profile" +const fixtureProjects = "projects" +const fixtureEducation = "education" +const fixtureExperience = "experience" +const fixtureRecommendations = "recommendations" + +type Fixture struct { + basePath string + fileType string +} + +func NewFixture() Fixture { + return Fixture{ + basePath: "./storage/fixture/", + fileType: "json", + } +} + +func (f Fixture) GetTalks() string { + return f.GetFileFor(fixtureTalks) +} + +func (f Fixture) GetSocial() string { + return f.GetFileFor(fixtureSocial) +} + +func (f Fixture) GetProfile() string { + return f.GetFileFor(fixtureProfile) +} + +func (f Fixture) GetProjects() string { + return f.GetFileFor(fixtureProjects) +} + +func (f Fixture) GetEducation() string { + return f.GetFileFor(fixtureEducation) +} + +func (f Fixture) GetExperience() string { + return f.GetFileFor(fixtureExperience) +} + +func (f Fixture) GetRecommendations() string { + return f.GetFileFor(fixtureRecommendations) +} + +func (f Fixture) GetFileFor(slug string) string { + return fmt.Sprintf("%s%s.%s", f.basePath, slug, f.fileType) +} diff --git a/metal/kernel/router_keep_alive_db_test.go b/metal/router/router_keep_alive_db_test.go similarity index 98% rename from metal/kernel/router_keep_alive_db_test.go rename to metal/router/router_keep_alive_db_test.go index 3d36de71..f4e58b78 100644 --- a/metal/kernel/router_keep_alive_db_test.go +++ b/metal/router/router_keep_alive_db_test.go @@ -1,4 +1,4 @@ -package kernel +package router import ( "net/http" diff --git a/metal/kernel/router_keep_alive_test.go b/metal/router/router_keep_alive_test.go similarity index 98% rename from metal/kernel/router_keep_alive_test.go rename to metal/router/router_keep_alive_test.go index f604d4c3..2f86ab11 100644 --- a/metal/kernel/router_keep_alive_test.go +++ b/metal/router/router_keep_alive_test.go @@ -1,4 +1,4 @@ -package kernel +package router import ( "net/http" diff --git a/metal/kernel/router_signature_test.go b/metal/router/router_signature_test.go similarity index 98% rename from metal/kernel/router_signature_test.go rename to metal/router/router_signature_test.go index 2b252cc6..f518711b 100644 --- a/metal/kernel/router_signature_test.go +++ b/metal/router/router_signature_test.go @@ -1,4 +1,4 @@ -package kernel +package router import ( "fmt" diff --git a/metal/kernel/web/page.go b/metal/router/web.go similarity index 55% rename from metal/kernel/web/page.go rename to metal/router/web.go index dec6a432..2cea2020 100644 --- a/metal/kernel/web/page.go +++ b/metal/router/web.go @@ -1,4 +1,4 @@ -package web +package router import ( baseHttp "net/http" @@ -7,7 +7,7 @@ import ( "github.com/oullin/pkg/http" ) -const HomePage = "/" +const HomePage = "/" // projects, talks, profile const AboutPage = "about" const ResumePage = "resume" const ProjectsPage = "projects" @@ -22,31 +22,42 @@ type ApiResource struct { Maker func(string) StaticRouteResource } -type Page struct { +type WebPage struct { Path string File string ApiResource map[string]ApiResource } -type Routes struct { +type WebsiteRoutes struct { OutputDir string Lang string SiteName string SiteURL string - Pages map[string]Page + Fixture Fixture + Pages map[string]WebPage } -func NewRoutes(e *env.Environment) *Routes { - return &Routes{ +func NewWebsiteRoutes(e *env.Environment) *WebsiteRoutes { + return &WebsiteRoutes{ SiteURL: e.App.URL, SiteName: e.App.Name, Lang: e.App.Lang(), OutputDir: e.Seo.SpaDir, - Pages: make(map[string]Page), + Fixture: NewFixture(), + Pages: make(map[string]WebPage), } } -func (r *Routes) AddPageFrom(path, file string, abstract func(string) StaticRouteResource) { +func (r *WebsiteRoutes) AddHome() { + //@todo: projects, talks, profile + + // 1 - Add the Home page. + // 2 - Add the Project fixture. + // 3 - Add the Talks fixture. + // 4 - Add the Profile fixture. +} + +func (r *WebsiteRoutes) AddPageFrom(path, file string, abstract func(string) StaticRouteResource) { resource := make(map[string]ApiResource, 1) resource[path] = ApiResource{ @@ -55,7 +66,7 @@ func (r *Routes) AddPageFrom(path, file string, abstract func(string) StaticRout Maker: abstract, } - page := Page{ + page := WebPage{ Path: path, File: file, ApiResource: resource, @@ -64,6 +75,6 @@ func (r *Routes) AddPageFrom(path, file string, abstract func(string) StaticRout r.MapResource(page, resource) } -func (r *Routes) MapResource(page Page, item map[string]ApiResource) { +func (r *WebsiteRoutes) MapResource(page WebPage, item map[string]ApiResource) { //WIP } From 80945bf5a12bba95410ffc34c823f50577af9416 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 22 Sep 2025 13:44:40 +0800 Subject: [PATCH 08/27] use fixture --- metal/router/factory.go | 102 ++++++++++++++++++-------- metal/router/fixture.go | 45 +++++++----- metal/router/router_signature_test.go | 2 +- 3 files changed, 99 insertions(+), 50 deletions(-) diff --git a/metal/router/factory.go b/metal/router/factory.go index a721dc95..8e08ba7f 100644 --- a/metal/router/factory.go +++ b/metal/router/factory.go @@ -2,7 +2,6 @@ package router import ( baseHttp "net/http" - "strings" "github.com/oullin/database" "github.com/oullin/database/repository" @@ -13,17 +12,6 @@ import ( "github.com/oullin/pkg/portal" ) -func addStaticRoute[H StaticRouteResource](r *Router, path, file string, maker func(string) H) { - abstract := maker(file) - resolver := r.PipelineFor(abstract.Handle) - - r.WebsiteRoutes.AddPageFrom(path, file, func(file string) StaticRouteResource { - return maker(file) - }) - - r.Mux.HandleFunc("GET "+path, resolver) -} - type Router struct { //@todo // --- make these fields required and use the validator to verify them. @@ -106,49 +94,101 @@ func (r *Router) KeepAliveDB() { } func (r *Router) Profile() { - path, file := r.StaticRouteFor(fixtureProfile) - addStaticRoute(r, path, file, handler.MakeProfileHandler) + maker := handler.MakeProfileHandler + + r.ComposeFixtures( + r.WebsiteRoutes.Fixture.GetProfile(), + func(file string) StaticRouteResource { + return maker(file) + }, + ) } func (r *Router) Experience() { - path, file := r.StaticRouteFor(fixtureExperience) - addStaticRoute(r, path, file, handler.MakeExperienceHandler) + maker := handler.MakeExperienceHandler + + r.ComposeFixtures( + r.WebsiteRoutes.Fixture.GetExperience(), + func(file string) StaticRouteResource { + return maker(file) + }, + ) } func (r *Router) Projects() { - path, file := r.StaticRouteFor(fixtureProjects) - addStaticRoute(r, path, file, handler.MakeProjectsHandler) + maker := handler.MakeProjectsHandler + + r.ComposeFixtures( + r.WebsiteRoutes.Fixture.GetProjects(), + func(file string) StaticRouteResource { + return maker(file) + }, + ) } func (r *Router) Social() { - path, file := r.StaticRouteFor(fixtureSocial) - addStaticRoute(r, path, file, handler.MakeSocialHandler) + maker := handler.MakeSocialHandler + + r.ComposeFixtures( + r.WebsiteRoutes.Fixture.GetSocial(), + func(file string) StaticRouteResource { + return maker(file) + }, + ) + } func (r *Router) Talks() { - path, file := r.StaticRouteFor(fixtureTalks) - addStaticRoute(r, path, file, handler.MakeTalksHandler) + maker := handler.MakeTalksHandler + + r.ComposeFixtures( + r.WebsiteRoutes.Fixture.GetTalks(), + func(file string) StaticRouteResource { + return maker(file) + }, + ) } func (r *Router) Education() { - path, file := r.StaticRouteFor(fixtureEducation) - addStaticRoute(r, path, file, handler.MakeEducationHandler) + maker := handler.MakeEducationHandler + + r.ComposeFixtures( + r.WebsiteRoutes.Fixture.GetEducation(), + func(file string) StaticRouteResource { + return maker(file) + }, + ) } func (r *Router) Recommendations() { maker := handler.MakeRecommendationsHandler - path, file := r.StaticRouteFor(fixtureRecommendations) - r.WebsiteRoutes.AddPageFrom(path, file, func(file string) StaticRouteResource { + r.ComposeFixtures( + r.WebsiteRoutes.Fixture.GetRecommendations(), + func(file string) StaticRouteResource { + return maker(file) + }, + ) +} + +func (r *Router) ComposeFixtures(fxt *Fixture, maker func(file string) StaticRouteResource) { + file := fxt.file + fullPath := fxt.fullPath + + r.WebsiteRoutes.AddPageFrom(file, fullPath, func(file string) StaticRouteResource { return maker(file) }) - addStaticRoute(r, path, file, maker) + addStaticRoute(r, file, fullPath, maker) } -func (r *Router) StaticRouteFor(slug string) (path string, file string) { - filepath := "/" + strings.Trim(slug, "/") - fixture := "./storage/fixture/" + slug + ".json" +func addStaticRoute[H StaticRouteResource](r *Router, path, file string, maker func(string) H) { + abstract := maker(file) + resolver := r.PipelineFor(abstract.Handle) - return filepath, fixture + r.WebsiteRoutes.AddPageFrom(path, file, func(file string) StaticRouteResource { + return maker(file) + }) + + r.Mux.HandleFunc("GET "+path, resolver) } diff --git a/metal/router/fixture.go b/metal/router/fixture.go index d5795047..426879cc 100644 --- a/metal/router/fixture.go +++ b/metal/router/fixture.go @@ -13,45 +13,54 @@ const fixtureExperience = "experience" const fixtureRecommendations = "recommendations" type Fixture struct { + file string basePath string - fileType string + fullPath string + mime string } func NewFixture() Fixture { return Fixture{ basePath: "./storage/fixture/", - fileType: "json", + mime: "json", } } -func (f Fixture) GetTalks() string { - return f.GetFileFor(fixtureTalks) +func (f *Fixture) GetTalks() *Fixture { + return f.resolveFor(fixtureTalks) } -func (f Fixture) GetSocial() string { - return f.GetFileFor(fixtureSocial) +func (f *Fixture) GetSocial() *Fixture { + return f.resolveFor(fixtureSocial) } -func (f Fixture) GetProfile() string { - return f.GetFileFor(fixtureProfile) +func (f *Fixture) GetProfile() *Fixture { + return f.resolveFor(fixtureProfile) } -func (f Fixture) GetProjects() string { - return f.GetFileFor(fixtureProjects) +func (f *Fixture) GetProjects() *Fixture { + return f.resolveFor(fixtureProjects) } -func (f Fixture) GetEducation() string { - return f.GetFileFor(fixtureEducation) +func (f *Fixture) GetEducation() *Fixture { + return f.resolveFor(fixtureEducation) } -func (f Fixture) GetExperience() string { - return f.GetFileFor(fixtureExperience) +func (f *Fixture) GetExperience() *Fixture { + return f.resolveFor(fixtureExperience) } -func (f Fixture) GetRecommendations() string { - return f.GetFileFor(fixtureRecommendations) +func (f *Fixture) GetRecommendations() *Fixture { + return f.resolveFor(fixtureRecommendations) } -func (f Fixture) GetFileFor(slug string) string { - return fmt.Sprintf("%s%s.%s", f.basePath, slug, f.fileType) +func (f *Fixture) resolveFor(slug string) *Fixture { + f.fullPath = f.getFileFor(slug) + f.file = slug + + return f +} + +func (f *Fixture) getFileFor(slug string) string { + return fmt.Sprintf("%s%s.%s", f.basePath, slug, f.mime) } diff --git a/metal/router/router_signature_test.go b/metal/router/router_signature_test.go index f518711b..58412ad9 100644 --- a/metal/router/router_signature_test.go +++ b/metal/router/router_signature_test.go @@ -18,7 +18,7 @@ func TestSignatureRoute_PublicMiddleware(t *testing.T) { Pipeline: middleware.Pipeline{ PublicMiddleware: middleware.MakePublicMiddleware("", false), }, - validator: portal.GetDefaultValidator(), + Validator: portal.GetDefaultValidator(), } r.Signature() From f3a06acb94644ca022c049ac69f1657b9ee1648f Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 22 Sep 2025 15:13:28 +0800 Subject: [PATCH 09/27] tweaks --- metal/cli/main.go | 44 +++++++++++++++++-- metal/cli/panel/menu.go | 1 + metal/router/{factory.go => router.go} | 11 +++-- .../mwguards/valid_timestamp_guard.go | 4 +- pkg/middleware/public_middleware.go | 4 ++ pkg/middleware/token_middleware.go | 9 +++- 6 files changed, 62 insertions(+), 11 deletions(-) rename metal/router/{factory.go => router.go} (92%) diff --git a/metal/cli/main.go b/metal/cli/main.go index cd112cad..51e9210b 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -1,6 +1,9 @@ package main import ( + "fmt" + "time" + "github.com/oullin/database" "github.com/oullin/metal/cli/accounts" "github.com/oullin/metal/cli/panel" @@ -62,6 +65,13 @@ func main() { continue } + return + case 5: + if err = printTimestamp(); err != nil { + cli.Errorln(err.Error()) + continue + } + return case 0: cli.Successln("Goodbye!") @@ -110,10 +120,6 @@ func createNewApiAccount(menu panel.Menu) error { return err } - if err = showApiAccount(menu); err != nil { - return err - } - return nil } @@ -140,3 +146,33 @@ func showApiAccount(menu panel.Menu) error { func generateSEO(menu panel.Menu) error { 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") + // Go uses a special reference 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 a62ff4dd..99225ef9 100644 --- a/metal/cli/panel/menu.go +++ b/metal/cli/panel/menu.go @@ -90,6 +90,7 @@ func (p *Menu) Print() { p.PrintOption("2) Create new API account.", inner) p.PrintOption("3) Show API accounts.", 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/router/factory.go b/metal/router/router.go similarity index 92% rename from metal/router/factory.go rename to metal/router/router.go index 8e08ba7f..58e3ba85 100644 --- a/metal/router/factory.go +++ b/metal/router/router.go @@ -2,6 +2,7 @@ package router import ( baseHttp "net/http" + "strings" "github.com/oullin/database" "github.com/oullin/database/repository" @@ -34,6 +35,7 @@ func (r *Router) PublicPipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerF func (r *Router) PipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc { tokenMiddleware := middleware.MakeTokenMiddleware( + r.Env, r.Pipeline.TokenHandler, r.Pipeline.ApiKeys, ) @@ -182,13 +184,14 @@ func (r *Router) ComposeFixtures(fxt *Fixture, maker func(file string) StaticRou addStaticRoute(r, file, fullPath, maker) } -func addStaticRoute[H StaticRouteResource](r *Router, path, file string, maker func(string) H) { - abstract := maker(file) +func addStaticRoute[H StaticRouteResource](r *Router, route, fixture string, maker func(string) H) { + abstract := maker(fixture) resolver := r.PipelineFor(abstract.Handle) - r.WebsiteRoutes.AddPageFrom(path, file, func(file string) StaticRouteResource { + r.WebsiteRoutes.AddPageFrom(route, fixture, func(file string) StaticRouteResource { return maker(file) }) - r.Mux.HandleFunc("GET "+path, resolver) + route = strings.TrimLeft(route, "/") + r.Mux.HandleFunc("GET /"+route, resolver) } diff --git a/pkg/middleware/mwguards/valid_timestamp_guard.go b/pkg/middleware/mwguards/valid_timestamp_guard.go index b4af03b7..a90c58cd 100644 --- a/pkg/middleware/mwguards/valid_timestamp_guard.go +++ b/pkg/middleware/mwguards/valid_timestamp_guard.go @@ -49,11 +49,11 @@ func (v ValidTimestamp) Validate(skew time.Duration, disallowFuture bool) *http. } if epoch < minValue { - return &http.ApiError{Message: "Request timestamp expired", Status: baseHttp.StatusUnauthorized} + return TimestampTooOldError("Request timestamp is too old or invalid", "Request timestamp invalid") } if epoch > maxValue { - return &http.ApiError{Message: "Request timestamp invalid", Status: baseHttp.StatusUnauthorized} + return TimestampTooNewError("Request timestamp is too recent or invalid", "Request timestamp invalid") } return nil diff --git a/pkg/middleware/public_middleware.go b/pkg/middleware/public_middleware.go index 7481b754..864a5b9b 100644 --- a/pkg/middleware/public_middleware.go +++ b/pkg/middleware/public_middleware.go @@ -43,6 +43,10 @@ func MakePublicMiddleware(allowedIP string, isProduction bool) PublicMiddleware func (p PublicMiddleware) Handle(next http.ApiHandler) http.ApiHandler { return func(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + if !p.isProduction { + return next(w, r) //@todo remove! + } + if err := p.GuardDependencies(); err != nil { return err } diff --git a/pkg/middleware/token_middleware.go b/pkg/middleware/token_middleware.go index ca1f9f2a..bb5d27da 100644 --- a/pkg/middleware/token_middleware.go +++ b/pkg/middleware/token_middleware.go @@ -11,6 +11,7 @@ import ( "github.com/oullin/database" "github.com/oullin/database/repository" "github.com/oullin/database/repository/repoentity" + "github.com/oullin/metal/env" "github.com/oullin/pkg/auth" "github.com/oullin/pkg/cache" "github.com/oullin/pkg/http" @@ -30,10 +31,12 @@ type TokenCheckMiddleware struct { TokenHandler *auth.TokenHandler ApiKeys *repository.ApiKeys rateLimiter *limiter.MemoryLimiter + env *env.Environment } -func MakeTokenMiddleware(tokenHandler *auth.TokenHandler, apiKeys *repository.ApiKeys) TokenCheckMiddleware { +func MakeTokenMiddleware(e *env.Environment, tokenHandler *auth.TokenHandler, apiKeys *repository.ApiKeys) TokenCheckMiddleware { return TokenCheckMiddleware{ + env: e, maxFailPerScope: 10, disallowFuture: true, ApiKeys: apiKeys, @@ -49,6 +52,10 @@ func MakeTokenMiddleware(tokenHandler *auth.TokenHandler, apiKeys *repository.Ap func (t TokenCheckMiddleware) Handle(next http.ApiHandler) http.ApiHandler { return func(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + if t.env.App.IsLocal() { //@todo remove! + return next(w, r) + } + reqID := strings.TrimSpace(r.Header.Get(portal.RequestIDHeader)) if reqID == "" { From 2a995180733ce36aaf36c0347cc21b832fbea287 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 22 Sep 2025 17:21:04 +0800 Subject: [PATCH 10/27] work on fetching --- metal/cli/main.go | 22 +++++++++-- metal/cli/seo/generator.go | 77 ++++++++++++++++++++++++++++++++------ metal/cli/seo/stub.html | 6 +-- metal/cli/seo/support.go | 34 +++++++++++++++++ metal/router/fixture.go | 41 +++++++++++++------- metal/router/router.go | 12 +++--- metal/router/web.go | 29 ++++++-------- 7 files changed, 166 insertions(+), 55 deletions(-) create mode 100644 metal/cli/seo/support.go diff --git a/metal/cli/main.go b/metal/cli/main.go index 51e9210b..c46ce6ea 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -8,6 +8,7 @@ import ( "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" @@ -60,7 +61,7 @@ func main() { return case 4: - if err = generateSEO(menu); err != nil { + if err = generateSEO(); err != nil { cli.Errorln(err.Error()) continue } @@ -143,7 +144,21 @@ func showApiAccount(menu panel.Menu) error { return nil } -func generateSEO(menu panel.Menu) error { +func generateSEO() error { + gen, err := seo.NewGenerator( + dbConn, + environment, + portal.GetDefaultValidator(), + ) + + if err != nil { + return err + } + + if err = gen.GenerateHome(); err != nil { + return err + } + return nil } @@ -171,8 +186,9 @@ func printTimestamp() error { fmt.Printf("RFC3339: %s\n", rfc3339Timestamp) // 5. Custom format (e.g., "YYYY-MM-DD HH:MM:SS") - // Go uses a special reference time for layouts: Mon Jan 2 15:04:05 MST 2006 + // 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/seo/generator.go b/metal/cli/seo/generator.go index edf1e28f..f5718c60 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -6,7 +6,11 @@ import ( htmltemplate "html/template" "github.com/oullin/database" + "github.com/oullin/handler" + "github.com/oullin/handler/payload" "github.com/oullin/metal/env" + "github.com/oullin/metal/router" + "github.com/oullin/pkg/portal" ) //go:embed stub.html @@ -21,34 +25,85 @@ type Template struct { } type Generator struct { - Tmpl Template - Env *env.Environment - DB *database.Connection + Tmpl Template + Env *env.Environment + Validator *portal.Validator + DB *database.Connection + WebsiteRoutes *router.WebsiteRoutes + Router *router.Router //@todo Remove! } -func NewGenerator(db *database.Connection, env *env.Environment) (*Generator, error) { +func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Validator) (*Generator, error) { tmpl := Template{ StubPath: "stub.html", - OutputDir: env.Seo.SpaDir, - Lang: env.App.Lang(), - SiteName: env.App.Name, SiteURL: env.App.URL, + SiteName: env.App.Name, + Lang: env.App.Lang(), + OutputDir: env.Seo.SpaDir, } return &Generator{ - Tmpl: tmpl, - Env: env, - DB: db, + DB: db, + Env: env, + Tmpl: tmpl, + Validator: val, + Router: &router.Router{}, + WebsiteRoutes: router.NewWebsiteRoutes(env), }, nil } +func (g *Generator) GenerateHome() error { + _, err := g.Tmpl.LoadTemplate() + + if err != nil { + return fmt.Errorf("loading template: %w", err) + } + + web := g.WebsiteRoutes + resource := make(map[string]func() router.StaticRouteResource) + + resource[router.FixtureProfile] = func() router.StaticRouteResource { + return handler.MakeProfileHandler(web.Fixture.GetProfileFile()) + } + + resource[router.FixtureTalks] = func() router.StaticRouteResource { + return handler.MakeTalksHandler(web.Fixture.GetTalksFile()) + } + + resource[router.FixtureProjects] = func() router.StaticRouteResource { + return handler.MakeProjectsHandler(web.Fixture.GetProfileFile()) + } + + var talks payload.TalksResponse + var profile payload.ProfileResponse + var projects payload.ProjectsResponse + + if err = Fetch[payload.ProfileResponse](&profile, resource[router.FixtureProfile]); err != nil { + return fmt.Errorf("error fetching profile: %w", err) + } + + if err = Fetch[payload.TalksResponse](&talks, resource[router.FixtureTalks]); err != nil { + return fmt.Errorf("error fetching talks: %w", err) + } + + if err = Fetch[payload.ProjectsResponse](&projects, resource[router.FixtureProjects]); err != nil { + return fmt.Errorf("error fetching projects: %w", err) + } + + fmt.Println("Here: ", profile) + + //PrintResponse(rr) + + return nil +} + func (t *Template) LoadTemplate() (*htmltemplate.Template, error) { raw, err := templatesFS.ReadFile(t.StubPath) if err != nil { return nil, fmt.Errorf("reading template: %w", err) } - tmpl, err := htmltemplate.New("public").Parse(string(raw)) + tmpl, err := htmltemplate.New("seo").Parse(string(raw)) if err != nil { return nil, fmt.Errorf("parsing template: %w", err) } diff --git a/metal/cli/seo/stub.html b/metal/cli/seo/stub.html index c63c94e3..b8885485 100644 --- a/metal/cli/seo/stub.html +++ b/metal/cli/seo/stub.html @@ -5,13 +5,13 @@ - - + + - + {{- range .HrefLang }} diff --git a/metal/cli/seo/support.go b/metal/cli/seo/support.go new file mode 100644 index 00000000..dc5d5327 --- /dev/null +++ b/metal/cli/seo/support.go @@ -0,0 +1,34 @@ +package seo + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + + "github.com/oullin/metal/router" +) + +func PrintResponse(rr *httptest.ResponseRecorder) { + fmt.Println("\n--- Captured Response ---") + + fmt.Printf("Status Code: %d\n", rr.Code) + fmt.Printf("Response Body: %s\n", rr.Body.String()) + fmt.Printf("Content-Type Header: %s\n", rr.Header().Get("Content-Type")) +} + +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/router/fixture.go b/metal/router/fixture.go index 426879cc..ffc592cd 100644 --- a/metal/router/fixture.go +++ b/metal/router/fixture.go @@ -4,13 +4,13 @@ import ( "fmt" ) -const fixtureTalks = "talks" -const fixtureSocial = "social" -const fixtureProfile = "profile" -const fixtureProjects = "projects" -const fixtureEducation = "education" -const fixtureExperience = "experience" -const fixtureRecommendations = "recommendations" +const FixtureTalks = "talks" +const FixtureSocial = "social" +const FixtureProfile = "profile" +const FixtureProjects = "projects" +const FixtureEducation = "education" +const FixtureExperience = "experience" +const FixtureRecommendations = "recommendations" type Fixture struct { file string @@ -27,31 +27,44 @@ func NewFixture() Fixture { } func (f *Fixture) GetTalks() *Fixture { - return f.resolveFor(fixtureTalks) + return f.resolveFor(FixtureTalks) +} + +func (f *Fixture) GetTalksFile() string { + return f.resolveFor(FixtureTalks).file + } func (f *Fixture) GetSocial() *Fixture { - return f.resolveFor(fixtureSocial) + return f.resolveFor(FixtureSocial) } func (f *Fixture) GetProfile() *Fixture { - return f.resolveFor(fixtureProfile) + return f.resolveFor(FixtureProfile) +} + +func (f *Fixture) GetProfileFile() string { + return f.resolveFor(FixtureProfile).fullPath } func (f *Fixture) GetProjects() *Fixture { - return f.resolveFor(fixtureProjects) + return f.resolveFor(FixtureProjects) +} + +func (f *Fixture) GetProjectsFile() string { + return f.resolveFor(FixtureProjects).file } func (f *Fixture) GetEducation() *Fixture { - return f.resolveFor(fixtureEducation) + return f.resolveFor(FixtureEducation) } func (f *Fixture) GetExperience() *Fixture { - return f.resolveFor(fixtureExperience) + return f.resolveFor(FixtureExperience) } func (f *Fixture) GetRecommendations() *Fixture { - return f.resolveFor(fixtureRecommendations) + return f.resolveFor(FixtureRecommendations) } func (f *Fixture) resolveFor(slug string) *Fixture { diff --git a/metal/router/router.go b/metal/router/router.go index 58e3ba85..3fd0728d 100644 --- a/metal/router/router.go +++ b/metal/router/router.go @@ -177,9 +177,9 @@ func (r *Router) ComposeFixtures(fxt *Fixture, maker func(file string) StaticRou file := fxt.file fullPath := fxt.fullPath - r.WebsiteRoutes.AddPageFrom(file, fullPath, func(file string) StaticRouteResource { - return maker(file) - }) + //r.WebsiteRoutes.AddPageFrom(file, fullPath, func(file string) StaticRouteResource { + // return maker(file) + //}) addStaticRoute(r, file, fullPath, maker) } @@ -188,9 +188,9 @@ func addStaticRoute[H StaticRouteResource](r *Router, route, fixture string, mak abstract := maker(fixture) resolver := r.PipelineFor(abstract.Handle) - r.WebsiteRoutes.AddPageFrom(route, fixture, func(file string) StaticRouteResource { - return maker(file) - }) + //r.WebsiteRoutes.AddPageFrom(route, fixture, func(file string) StaticRouteResource { + // return maker(file) + //}) route = strings.TrimLeft(route, "/") r.Mux.HandleFunc("GET /"+route, resolver) diff --git a/metal/router/web.go b/metal/router/web.go index 2cea2020..ada64d6e 100644 --- a/metal/router/web.go +++ b/metal/router/web.go @@ -48,15 +48,6 @@ func NewWebsiteRoutes(e *env.Environment) *WebsiteRoutes { } } -func (r *WebsiteRoutes) AddHome() { - //@todo: projects, talks, profile - - // 1 - Add the Home page. - // 2 - Add the Project fixture. - // 3 - Add the Talks fixture. - // 4 - Add the Profile fixture. -} - func (r *WebsiteRoutes) AddPageFrom(path, file string, abstract func(string) StaticRouteResource) { resource := make(map[string]ApiResource, 1) @@ -65,16 +56,18 @@ func (r *WebsiteRoutes) AddPageFrom(path, file string, abstract func(string) Sta File: file, Maker: abstract, } + // + //page := WebPage{ + // Path: path, + // File: file, + // ApiResource: resource, + //} - page := WebPage{ - Path: path, - File: file, - ApiResource: resource, - } - - r.MapResource(page, resource) + //r.MapResource(page, resource) } -func (r *WebsiteRoutes) MapResource(page WebPage, item map[string]ApiResource) { - //WIP +func (r *WebsiteRoutes) MapResource(url string) map[string]ApiResource { + resource := make(map[string]ApiResource, 1) + + return resource } From 632619ebdcc8c9d8283654df645b9fb70aea08d6 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 23 Sep 2025 10:42:09 +0800 Subject: [PATCH 11/27] fix parsing --- metal/cli/seo/generator.go | 54 ++++++++++++++++++++++++++++---------- metal/router/fixture.go | 4 +-- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index f5718c60..2de5b7ca 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -1,9 +1,12 @@ package seo import ( + "bytes" "embed" "fmt" htmltemplate "html/template" + "os" + "path/filepath" "github.com/oullin/database" "github.com/oullin/handler" @@ -22,6 +25,7 @@ type Template struct { Lang string SiteName string SiteURL string + HTML *htmltemplate.Template } type Generator struct { @@ -34,7 +38,7 @@ type Generator struct { } func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Validator) (*Generator, error) { - tmpl := Template{ + template := Template{ StubPath: "stub.html", SiteURL: env.App.URL, SiteName: env.App.Name, @@ -42,10 +46,16 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val OutputDir: env.Seo.SpaDir, } + if html, err := template.LoadTemplate(); err != nil { + return nil, fmt.Errorf("there was an issue loading the template [%s]: %w", template.StubPath, err) + } else { + template.HTML = html + } + return &Generator{ DB: db, Env: env, - Tmpl: tmpl, + Tmpl: template, Validator: val, Router: &router.Router{}, WebsiteRoutes: router.NewWebsiteRoutes(env), @@ -53,12 +63,7 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val } func (g *Generator) GenerateHome() error { - _, err := g.Tmpl.LoadTemplate() - - if err != nil { - return fmt.Errorf("loading template: %w", err) - } - + var err error web := g.WebsiteRoutes resource := make(map[string]func() router.StaticRouteResource) @@ -71,28 +76,49 @@ func (g *Generator) GenerateHome() error { } resource[router.FixtureProjects] = func() router.StaticRouteResource { - return handler.MakeProjectsHandler(web.Fixture.GetProfileFile()) + return handler.MakeProjectsHandler(web.Fixture.GetProjectsFile()) } var talks payload.TalksResponse var profile payload.ProfileResponse var projects payload.ProjectsResponse + var data = struct { + Talks payload.TalksResponse + Projects payload.ProjectsResponse + Profile payload.ProfileResponse + }{ + Talks: talks, + Profile: profile, + Projects: projects, + } + if err = Fetch[payload.ProfileResponse](&profile, resource[router.FixtureProfile]); err != nil { - return fmt.Errorf("error fetching profile: %w", err) + return fmt.Errorf("home: error fetching profile: %w", err) } if err = Fetch[payload.TalksResponse](&talks, resource[router.FixtureTalks]); err != nil { - return fmt.Errorf("error fetching talks: %w", err) + return fmt.Errorf("home: error fetching talks: %w", err) } if err = Fetch[payload.ProjectsResponse](&projects, resource[router.FixtureProjects]); err != nil { - return fmt.Errorf("error fetching projects: %w", err) + return fmt.Errorf("home: error fetching projects: %w", err) + } + + var buffer bytes.Buffer + if err = g.Tmpl.HTML.Execute(&buffer, data); err != nil { + return fmt.Errorf("home: rendering template: %w", err) } - fmt.Println("Here: ", profile) + if err = os.MkdirAll(filepath.Dir(g.Tmpl.OutputDir), 0o755); err != nil { + return fmt.Errorf("home: creating directory for %s: %w", g.Tmpl.OutputDir, err) + } + + if err = os.WriteFile(g.Tmpl.OutputDir, buffer.Bytes(), 0o644); err != nil { + return fmt.Errorf("writing %s: %w", g.Tmpl.OutputDir, err) + } - //PrintResponse(rr) + fmt.Println("Home: Done.") return nil } diff --git a/metal/router/fixture.go b/metal/router/fixture.go index ffc592cd..4decc88b 100644 --- a/metal/router/fixture.go +++ b/metal/router/fixture.go @@ -31,7 +31,7 @@ func (f *Fixture) GetTalks() *Fixture { } func (f *Fixture) GetTalksFile() string { - return f.resolveFor(FixtureTalks).file + return f.resolveFor(FixtureTalks).fullPath } @@ -52,7 +52,7 @@ func (f *Fixture) GetProjects() *Fixture { } func (f *Fixture) GetProjectsFile() string { - return f.resolveFor(FixtureProjects).file + return f.resolveFor(FixtureProjects).fullPath } func (f *Fixture) GetEducation() *Fixture { From cd61e5089daeff54b854b600d4da774743ece364 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 23 Sep 2025 12:28:21 +0800 Subject: [PATCH 12/27] map --- metal/cli/seo/data.go | 22 +++--- metal/cli/seo/generator.go | 86 +++++++++++++++++---- metal/cli/seo/jsonld.go | 151 +++++++++++++++++++++++++++++++++++++ metal/cli/seo/manifest.go | 131 ++++++++++++++++++++++++++++++++ metal/cli/seo/web.go | 28 +++++++ metal/router/web.go | 2 +- 6 files changed, 396 insertions(+), 24 deletions(-) create mode 100644 metal/cli/seo/jsonld.go create mode 100644 metal/cli/seo/manifest.go create mode 100644 metal/cli/seo/web.go diff --git a/metal/cli/seo/data.go b/metal/cli/seo/data.go index 52089ea7..9a4d1061 100644 --- a/metal/cli/seo/data.go +++ b/metal/cli/seo/data.go @@ -13,31 +13,33 @@ type TemplateData struct { Lang string `validate:"required,oneof=en"` Title string `validate:"required,min=10"` Description string `validate:"required,min=10"` - Canonical string `validate:"required,url"` //website url - Robots string `validate:"required"` // default: index,follow - ThemeColor string `validate:"required"` // default: #0E172B -> dark + Canonical string `validate:"required,url"` + Robots string `validate:"required"` + ThemeColor string `validate:"required"` JsonLD htmltemplate.JS `validate:"required"` OGTagOg TagOgData `validate:"required"` Twitter TwitterData `validate:"required"` HrefLang []HrefLangData `validate:"required"` Favicons []FaviconData `validate:"required"` - Manifest string `validate:"required"` + Manifest htmltemplate.JS `validate:"required"` AppleTouchIcon string `validate:"required"` + Categories []string `validate:"required"` + BgColor string `validate:"required"` } type TagOgData struct { - Type string `validate:"required,oneof=website"` //website - Image string `validate:"required,url"` //https://oullin.io/assets/about-Dt5rMl63.jpg + Type string `validate:"required,oneof=website"` + Image string `validate:"required,url"` ImageAlt string `validate:"required,min=10"` - ImageWidth string `validate:"required"` //600 - ImageHeight string `validate:"required"` //400 + ImageWidth string `validate:"required"` + ImageHeight string `validate:"required"` SiteName string `validate:"required,min=5"` - Locale string `validate:"required,min=5"` //en_GB + Locale string `validate:"required,min=5"` } type TwitterData struct { Card string `validate:"required,oneof=summary_large_image"` - Image string `validate:"required,url"` //https://oullin.io/assets/about-Dt5rMl63.jpg + Image string `validate:"required,url"` ImageAlt string `validate:"required,min=10"` } diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 2de5b7ca..b26d27c9 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -20,12 +20,17 @@ import ( var templatesFS embed.FS type Template struct { - StubPath string - OutputDir string - Lang string - SiteName string - SiteURL string - HTML *htmltemplate.Template + StubPath string + OutputDir string + Lang string + SiteName string + SiteURL string + LogoURL string + SameAsURL []string + WebRepoURL string + APIRepoURL string + AboutPhotoUrl string + HTML *htmltemplate.Template } type Generator struct { @@ -39,11 +44,17 @@ type Generator struct { func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Validator) (*Generator, error) { template := Template{ - StubPath: "stub.html", - SiteURL: env.App.URL, - SiteName: env.App.Name, - Lang: env.App.Lang(), - OutputDir: env.Seo.SpaDir, + LogoURL: LogoUrl, + WebRepoURL: RepoWebUrl, + APIRepoURL: RepoApiUrl, + StubPath: "stub.html", + SiteURL: env.App.URL, + SiteName: env.App.Name, + AboutPhotoUrl: AboutPhotoUrl, + Lang: env.App.Lang(), + OutputDir: env.Seo.SpaDir, + HTML: &htmltemplate.Template{}, + SameAsURL: []string{RepoApiUrl, RepoWebUrl, GocantoUrl}, } if html, err := template.LoadTemplate(); err != nil { @@ -55,8 +66,8 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val return &Generator{ DB: db, Env: env, - Tmpl: template, Validator: val, + Tmpl: template, Router: &router.Router{}, WebsiteRoutes: router.NewWebsiteRoutes(env), }, nil @@ -85,8 +96,8 @@ func (g *Generator) GenerateHome() error { var data = struct { Talks payload.TalksResponse - Projects payload.ProjectsResponse Profile payload.ProfileResponse + Projects payload.ProjectsResponse }{ Talks: talks, Profile: profile, @@ -123,6 +134,55 @@ func (g *Generator) GenerateHome() error { return nil } +func (g *Generator) Generate() error { + og := TagOgData{ + ImageWidth: "600", + ImageHeight: "400", + Type: "website", + Locale: g.Tmpl.Lang, + ImageAlt: g.Tmpl.SiteName, + SiteName: g.Tmpl.SiteName, + Image: g.Tmpl.AboutPhotoUrl, + } + + twitter := TwitterData{ + Card: "summary_large_image", + Image: g.Tmpl.AboutPhotoUrl, + ImageAlt: g.Tmpl.SiteName, + } + + data := TemplateData{ + OGTagOg: og, + Robots: Robots, + Twitter: twitter, + ThemeColor: ThemeColor, + Lang: g.Tmpl.Lang, + Description: Description, + Canonical: g.Tmpl.SiteURL, + AppleTouchIcon: g.Tmpl.LogoURL, + Title: g.Tmpl.SiteName, + JsonLD: NewJsonID(g.Tmpl).Render(), + Categories: []string{"one", "two"}, //@todo Fetch this! + HrefLang: []HrefLangData{ + {Lang: g.Tmpl.Lang, Href: g.Tmpl.SiteURL}, + }, + Favicons: []FaviconData{ + { + Rel: "icon", + Sizes: "48x48", + Type: "image/ico", + Href: g.Tmpl.SiteURL + "/favicon.ico", + }, + }, + } + + manifest := NewManifest(g.Tmpl, data) + + data.Manifest = manifest.Render() + + return nil +} + func (t *Template) LoadTemplate() (*htmltemplate.Template, error) { raw, err := templatesFS.ReadFile(t.StubPath) if err != nil { diff --git a/metal/cli/seo/jsonld.go b/metal/cli/seo/jsonld.go new file mode 100644 index 00000000..be21085f --- /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 Template) *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 HTML 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..784b8d33 --- /dev/null +++ b/metal/cli/seo/manifest.go @@ -0,0 +1,131 @@ +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 Template, data TemplateData) *Manifest { + favicon := data.Favicons[1] + icons := []ManifestIcon{ + {Src: favicon.Href, Sizes: favicon.Sizes, Type: favicon.Type, 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: WebPostUrl, + Name: WebPostName, + ShortName: WebPostName, + }, + { + 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/web.go b/metal/cli/seo/web.go new file mode 100644 index 00000000..df7a904a --- /dev/null +++ b/metal/cli/seo/web.go @@ -0,0 +1,28 @@ +package seo + +const FoundedYear = 2020 +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" + +const WebHomeName = "Home" +const WebHomeUrl = "/" + +const WebPostName = "Post" +const WebPostUrl = "/post/:slug" + +const WebAboutName = "About" +const WebAboutUrl = "/post/:slug" + +const WebProjectsName = "Projects" +const WebProjectsUrl = "/post/:slug" + +const WebResumeName = "Resume" +const WebResumeUrl = "/post/:slug" + +const Description = "Gustavo is a full-stack Software Engineer leader with over two decades of 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 Robots = "index, follow" +const ThemeColor = "light dark" diff --git a/metal/router/web.go b/metal/router/web.go index ada64d6e..3b8ca83b 100644 --- a/metal/router/web.go +++ b/metal/router/web.go @@ -56,7 +56,7 @@ func (r *WebsiteRoutes) AddPageFrom(path, file string, abstract func(string) Sta File: file, Maker: abstract, } - // + //@todo Remove this! //page := WebPage{ // Path: path, // File: file, From 2a0790e1bbd634e89e2ecbf520eb6dd8e458f25c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 23 Sep 2025 13:43:32 +0800 Subject: [PATCH 13/27] start working on validation --- metal/cli/accounts/handler.go | 2 +- metal/cli/accounts/handler_test.go | 18 ++------ metal/cli/seo/data.go | 7 --- metal/cli/seo/generator.go | 68 ++++++++++++++++++++---------- 4 files changed, 50 insertions(+), 45 deletions(-) diff --git a/metal/cli/accounts/handler.go b/metal/cli/accounts/handler.go index 586d4568..9a31f59e 100644 --- a/metal/cli/accounts/handler.go +++ b/metal/cli/accounts/handler.go @@ -25,7 +25,7 @@ func (h Handler) CreateAccount(accountName string) error { return fmt.Errorf("failed to create account [%s]: %v", accountName, err) } - if h.print(token, item) != nil { + if err = h.print(token, item); err != nil { return fmt.Errorf("could not decode the given account [%s] keys: %v", item.AccountName, err) } 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/seo/data.go b/metal/cli/seo/data.go index 9a4d1061..f9c8fb7a 100644 --- a/metal/cli/seo/data.go +++ b/metal/cli/seo/data.go @@ -2,13 +2,6 @@ package seo import htmltemplate "html/template" -type SEO struct { - SpaPublicDir string `validate:"required,dirpath"` - SiteURL string `validate:"required,url"` - Lang string `validate:"required,oneof=en"` - SiteName string `validate:"required,min=10"` -} - type TemplateData struct { Lang string `validate:"required,oneof=en"` Title string `validate:"required,min=10"` diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index b26d27c9..212fc81e 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -20,26 +20,25 @@ import ( var templatesFS embed.FS type Template struct { - StubPath string - OutputDir string - Lang string - SiteName string - SiteURL string - LogoURL string - SameAsURL []string - WebRepoURL string - APIRepoURL string - AboutPhotoUrl string - HTML *htmltemplate.Template + StubPath string `validate:"required,oneof=stub.html"` + OutputDir string `validate:"required"` + Lang string `validate:"required,oneof=en_GB"` + SiteName string `validate:"required"` + SiteURL string `validate:"required,uri"` + LogoURL string `validate:"required,uri"` + SameAsURL []string `validate:"required"` + WebRepoURL string `validate:"required,uri"` + APIRepoURL string `validate:"required,uri"` + AboutPhotoUrl string `validate:"required,uri"` + HTML *htmltemplate.Template `validate:"required"` } type Generator struct { - Tmpl Template - Env *env.Environment - Validator *portal.Validator - DB *database.Connection - WebsiteRoutes *router.WebsiteRoutes - Router *router.Router //@todo Remove! + Tmpl Template `validate:"required"` + Env *env.Environment `validate:"required"` + Validator *portal.Validator `validate:"required"` + DB *database.Connection `validate:"required"` + WebsiteRoutes *router.WebsiteRoutes `validate:"required"` } func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Validator) (*Generator, error) { @@ -63,14 +62,23 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val template.HTML = html } - return &Generator{ + if _, err := val.Rejects(template); err != nil { + return nil, fmt.Errorf("invalid template: %w", err) + } + + gen := Generator{ DB: db, Env: env, Validator: val, Tmpl: template, - Router: &router.Router{}, WebsiteRoutes: router.NewWebsiteRoutes(env), - }, nil + } + + if _, err := val.Rejects(gen); err != nil { + return nil, fmt.Errorf("invalid generator: %w", err) + } + + return &gen, nil } func (g *Generator) GenerateHome() error { @@ -134,7 +142,7 @@ func (g *Generator) GenerateHome() error { return nil } -func (g *Generator) Generate() error { +func (g *Generator) Generate() (TemplateData, error) { og := TagOgData{ ImageWidth: "600", ImageHeight: "400", @@ -180,7 +188,23 @@ func (g *Generator) Generate() error { data.Manifest = manifest.Render() - return nil + if _, err := g.Validator.Rejects(og); err != nil { + return TemplateData{}, fmt.Errorf("generate: invalid og data: %w", err) + } + + if _, err := g.Validator.Rejects(twitter); err != nil { + return TemplateData{}, fmt.Errorf("generate: invalid twitter data: %w", err) + } + + if _, err := g.Validator.Rejects(twitter); err != nil { + return TemplateData{}, fmt.Errorf("generate: invalid twitter data: %w", err) + } + + if _, err := g.Validator.Rejects(data); err != nil { + return TemplateData{}, fmt.Errorf("generate: invalid template data: %w", err) + } + + return data, nil } func (t *Template) LoadTemplate() (*htmltemplate.Template, error) { From 122d3638433eb824f37c3fb2dbaf5bd231d27cfc Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 23 Sep 2025 13:55:26 +0800 Subject: [PATCH 14/27] wip --- metal/cli/seo/generator.go | 27 ++++++++++++++------------- metal/cli/seo/manifest.go | 10 +++++++--- metal/cli/seo/support.go | 2 +- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 212fc81e..1d3c4cfe 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -102,16 +102,6 @@ func (g *Generator) GenerateHome() error { var profile payload.ProfileResponse var projects payload.ProjectsResponse - var data = struct { - Talks payload.TalksResponse - Profile payload.ProfileResponse - Projects payload.ProjectsResponse - }{ - Talks: talks, - Profile: profile, - Projects: projects, - } - if err = Fetch[payload.ProfileResponse](&profile, resource[router.FixtureProfile]); err != nil { return fmt.Errorf("home: error fetching profile: %w", err) } @@ -125,16 +115,27 @@ func (g *Generator) GenerateHome() error { } var buffer bytes.Buffer + var data = struct { + Talks payload.TalksResponse + Profile payload.ProfileResponse + Projects payload.ProjectsResponse + }{ + Talks: talks, + Profile: profile, + Projects: projects, + } + if err = g.Tmpl.HTML.Execute(&buffer, data); err != nil { return fmt.Errorf("home: rendering template: %w", err) } - if err = os.MkdirAll(filepath.Dir(g.Tmpl.OutputDir), 0o755); err != nil { + if err = os.MkdirAll(g.Tmpl.OutputDir, 0o755); err != nil { return fmt.Errorf("home: creating directory for %s: %w", g.Tmpl.OutputDir, err) } - if err = os.WriteFile(g.Tmpl.OutputDir, buffer.Bytes(), 0o644); err != nil { - return fmt.Errorf("writing %s: %w", g.Tmpl.OutputDir, err) + out := filepath.Join(g.Tmpl.OutputDir, "index.html") + if err = os.WriteFile(out, buffer.Bytes(), 0o644); err != nil { + return fmt.Errorf("writing %s: %w", out, err) } fmt.Println("Home: Done.") diff --git a/metal/cli/seo/manifest.go b/metal/cli/seo/manifest.go index 784b8d33..44c48fca 100644 --- a/metal/cli/seo/manifest.go +++ b/metal/cli/seo/manifest.go @@ -38,9 +38,13 @@ type ManifestShortcut struct { } func NewManifest(tmpl Template, data TemplateData) *Manifest { - favicon := data.Favicons[1] - icons := []ManifestIcon{ - {Src: favicon.Href, Sizes: favicon.Sizes, Type: favicon.Type, Purpose: "any"}, + 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{ diff --git a/metal/cli/seo/support.go b/metal/cli/seo/support.go index dc5d5327..f73b9c48 100644 --- a/metal/cli/seo/support.go +++ b/metal/cli/seo/support.go @@ -26,7 +26,7 @@ func Fetch[T any](response *T, handler func() router.StaticRouteResource) error return err } - if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + if err := json.Unmarshal(rr.Body.Bytes(), response); err != nil { return err } From bbb7129b0085f2c3ddf1a7cc5f57c18464c1973c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 23 Sep 2025 14:05:44 +0800 Subject: [PATCH 15/27] wip --- metal/cli/seo/manifest.go | 6 --- metal/cli/seo/web.go | 11 ++--- metal/router/router.go | 31 ++++--------- metal/router/static.go | 30 ++++++++++++ metal/router/web.go | 73 ------------------------------ pkg/middleware/token_middleware.go | 2 +- 6 files changed, 45 insertions(+), 108 deletions(-) create mode 100644 metal/router/static.go delete mode 100644 metal/router/web.go diff --git a/metal/cli/seo/manifest.go b/metal/cli/seo/manifest.go index 44c48fca..9ae5e09c 100644 --- a/metal/cli/seo/manifest.go +++ b/metal/cli/seo/manifest.go @@ -66,12 +66,6 @@ func NewManifest(tmpl Template, data TemplateData) *Manifest { Name: WebHomeName, ShortName: WebHomeName, }, - { - Icons: icons, - URL: WebPostUrl, - Name: WebPostName, - ShortName: WebPostName, - }, { Icons: icons, URL: WebProjectsUrl, diff --git a/metal/cli/seo/web.go b/metal/cli/seo/web.go index df7a904a..b0c9226f 100644 --- a/metal/cli/seo/web.go +++ b/metal/cli/seo/web.go @@ -11,18 +11,15 @@ const AboutPhotoUrl = "https://oullin.io/assets/about-Dt5rMl63.jpg" const WebHomeName = "Home" const WebHomeUrl = "/" -const WebPostName = "Post" -const WebPostUrl = "/post/:slug" - const WebAboutName = "About" -const WebAboutUrl = "/post/:slug" +const WebAboutUrl = "/about" const WebProjectsName = "Projects" -const WebProjectsUrl = "/post/:slug" +const WebProjectsUrl = "/projects" const WebResumeName = "Resume" -const WebResumeUrl = "/post/:slug" +const WebResumeUrl = "/resume" -const Description = "Gustavo is a full-stack Software Engineer leader with over two decades of 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 Robots = "index, follow" const ThemeColor = "light dark" +const Description = "Gustavo is a full-stack Software Engineer leader with over two decades of 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/router/router.go b/metal/router/router.go index 3fd0728d..ed70ce52 100644 --- a/metal/router/router.go +++ b/metal/router/router.go @@ -14,14 +14,12 @@ import ( ) type Router struct { - //@todo - // --- make these fields required and use the validator to verify them. + WebsiteRoutes *WebsiteRoutes Env *env.Environment + Validator *portal.Validator Mux *baseHttp.ServeMux Pipeline middleware.Pipeline Db *database.Connection - Validator *portal.Validator - WebsiteRoutes *WebsiteRoutes } func (r *Router) PublicPipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc { @@ -98,7 +96,7 @@ func (r *Router) KeepAliveDB() { func (r *Router) Profile() { maker := handler.MakeProfileHandler - r.ComposeFixtures( + r.composeFixtures( r.WebsiteRoutes.Fixture.GetProfile(), func(file string) StaticRouteResource { return maker(file) @@ -109,7 +107,7 @@ func (r *Router) Profile() { func (r *Router) Experience() { maker := handler.MakeExperienceHandler - r.ComposeFixtures( + r.composeFixtures( r.WebsiteRoutes.Fixture.GetExperience(), func(file string) StaticRouteResource { return maker(file) @@ -120,7 +118,7 @@ func (r *Router) Experience() { func (r *Router) Projects() { maker := handler.MakeProjectsHandler - r.ComposeFixtures( + r.composeFixtures( r.WebsiteRoutes.Fixture.GetProjects(), func(file string) StaticRouteResource { return maker(file) @@ -131,19 +129,18 @@ func (r *Router) Projects() { func (r *Router) Social() { maker := handler.MakeSocialHandler - r.ComposeFixtures( + r.composeFixtures( r.WebsiteRoutes.Fixture.GetSocial(), func(file string) StaticRouteResource { return maker(file) }, ) - } func (r *Router) Talks() { maker := handler.MakeTalksHandler - r.ComposeFixtures( + r.composeFixtures( r.WebsiteRoutes.Fixture.GetTalks(), func(file string) StaticRouteResource { return maker(file) @@ -154,7 +151,7 @@ func (r *Router) Talks() { func (r *Router) Education() { maker := handler.MakeEducationHandler - r.ComposeFixtures( + r.composeFixtures( r.WebsiteRoutes.Fixture.GetEducation(), func(file string) StaticRouteResource { return maker(file) @@ -165,7 +162,7 @@ func (r *Router) Education() { func (r *Router) Recommendations() { maker := handler.MakeRecommendationsHandler - r.ComposeFixtures( + r.composeFixtures( r.WebsiteRoutes.Fixture.GetRecommendations(), func(file string) StaticRouteResource { return maker(file) @@ -173,14 +170,10 @@ func (r *Router) Recommendations() { ) } -func (r *Router) ComposeFixtures(fxt *Fixture, maker func(file string) StaticRouteResource) { +func (r *Router) composeFixtures(fxt *Fixture, maker func(file string) StaticRouteResource) { file := fxt.file fullPath := fxt.fullPath - //r.WebsiteRoutes.AddPageFrom(file, fullPath, func(file string) StaticRouteResource { - // return maker(file) - //}) - addStaticRoute(r, file, fullPath, maker) } @@ -188,10 +181,6 @@ func addStaticRoute[H StaticRouteResource](r *Router, route, fixture string, mak abstract := maker(fixture) resolver := r.PipelineFor(abstract.Handle) - //r.WebsiteRoutes.AddPageFrom(route, fixture, func(file string) StaticRouteResource { - // return maker(file) - //}) - route = strings.TrimLeft(route, "/") r.Mux.HandleFunc("GET /"+route, resolver) } diff --git a/metal/router/static.go b/metal/router/static.go new file mode 100644 index 00000000..7ffcc511 --- /dev/null +++ b/metal/router/static.go @@ -0,0 +1,30 @@ +package router + +import ( + baseHttp "net/http" + + "github.com/oullin/metal/env" + "github.com/oullin/pkg/http" +) + +type StaticRouteResource interface { + Handle(baseHttp.ResponseWriter, *baseHttp.Request) *http.ApiError +} + +type WebsiteRoutes struct { + OutputDir string + Lang string + SiteName string + SiteURL string + Fixture Fixture +} + +func NewWebsiteRoutes(e *env.Environment) *WebsiteRoutes { + return &WebsiteRoutes{ + SiteURL: e.App.URL, + SiteName: e.App.Name, + Lang: e.App.Lang(), + OutputDir: e.Seo.SpaDir, + Fixture: NewFixture(), + } +} diff --git a/metal/router/web.go b/metal/router/web.go deleted file mode 100644 index 3b8ca83b..00000000 --- a/metal/router/web.go +++ /dev/null @@ -1,73 +0,0 @@ -package router - -import ( - baseHttp "net/http" - - "github.com/oullin/metal/env" - "github.com/oullin/pkg/http" -) - -const HomePage = "/" // projects, talks, profile -const AboutPage = "about" -const ResumePage = "resume" -const ProjectsPage = "projects" - -type StaticRouteResource interface { - Handle(baseHttp.ResponseWriter, *baseHttp.Request) *http.ApiError -} - -type ApiResource struct { - Path string - File string - Maker func(string) StaticRouteResource -} - -type WebPage struct { - Path string - File string - ApiResource map[string]ApiResource -} - -type WebsiteRoutes struct { - OutputDir string - Lang string - SiteName string - SiteURL string - Fixture Fixture - Pages map[string]WebPage -} - -func NewWebsiteRoutes(e *env.Environment) *WebsiteRoutes { - return &WebsiteRoutes{ - SiteURL: e.App.URL, - SiteName: e.App.Name, - Lang: e.App.Lang(), - OutputDir: e.Seo.SpaDir, - Fixture: NewFixture(), - Pages: make(map[string]WebPage), - } -} - -func (r *WebsiteRoutes) AddPageFrom(path, file string, abstract func(string) StaticRouteResource) { - resource := make(map[string]ApiResource, 1) - - resource[path] = ApiResource{ - Path: path, - File: file, - Maker: abstract, - } - //@todo Remove this! - //page := WebPage{ - // Path: path, - // File: file, - // ApiResource: resource, - //} - - //r.MapResource(page, resource) -} - -func (r *WebsiteRoutes) MapResource(url string) map[string]ApiResource { - resource := make(map[string]ApiResource, 1) - - return resource -} diff --git a/pkg/middleware/token_middleware.go b/pkg/middleware/token_middleware.go index bb5d27da..d3e34b3b 100644 --- a/pkg/middleware/token_middleware.go +++ b/pkg/middleware/token_middleware.go @@ -52,7 +52,7 @@ func MakeTokenMiddleware(e *env.Environment, tokenHandler *auth.TokenHandler, ap func (t TokenCheckMiddleware) Handle(next http.ApiHandler) http.ApiHandler { return func(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - if t.env.App.IsLocal() { //@todo remove! + if t.env != nil && t.env.App.IsLocal() { //@todo remove! return next(w, r) } From e9ba7115a24fa7f13a124689b6d9826fa9733c5c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 23 Sep 2025 14:33:09 +0800 Subject: [PATCH 16/27] format --- .env.example | 2 +- metal/cli/seo/generator.go | 38 ++++++++++++++++---------------------- metal/cli/seo/web.go | 6 +++--- 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/.env.example b/.env.example index 1e6230a9..f798bd7f 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ 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. diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 1d3c4cfe..7c86b68e 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -34,15 +34,15 @@ type Template struct { } type Generator struct { - Tmpl Template `validate:"required"` - Env *env.Environment `validate:"required"` - Validator *portal.Validator `validate:"required"` - DB *database.Connection `validate:"required"` - WebsiteRoutes *router.WebsiteRoutes `validate:"required"` + Tmpl Template + 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) { - template := Template{ + tmpl := Template{ LogoURL: LogoUrl, WebRepoURL: RepoWebUrl, APIRepoURL: RepoApiUrl, @@ -56,29 +56,23 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val SameAsURL: []string{RepoApiUrl, RepoWebUrl, GocantoUrl}, } - if html, err := template.LoadTemplate(); err != nil { - return nil, fmt.Errorf("there was an issue loading the template [%s]: %w", template.StubPath, err) - } else { - template.HTML = html + if _, err := val.Rejects(tmpl); err != nil { + return nil, fmt.Errorf("invalid template: %w", err) } - if _, err := val.Rejects(template); err != nil { - return nil, fmt.Errorf("invalid template: %w", err) + if html, err := tmpl.Load(); err != nil { + return nil, fmt.Errorf("there was an issue loading the template [%s]: %w", tmpl.StubPath, err) + } else { + tmpl.HTML = html } - gen := Generator{ + return &Generator{ DB: db, Env: env, Validator: val, - Tmpl: template, + Tmpl: tmpl, WebsiteRoutes: router.NewWebsiteRoutes(env), - } - - if _, err := val.Rejects(gen); err != nil { - return nil, fmt.Errorf("invalid generator: %w", err) - } - - return &gen, nil + }, nil } func (g *Generator) GenerateHome() error { @@ -208,7 +202,7 @@ func (g *Generator) Generate() (TemplateData, error) { return data, nil } -func (t *Template) LoadTemplate() (*htmltemplate.Template, error) { +func (t *Template) Load() (*htmltemplate.Template, error) { raw, err := templatesFS.ReadFile(t.StubPath) if err != nil { return nil, fmt.Errorf("reading template: %w", err) diff --git a/metal/cli/seo/web.go b/metal/cli/seo/web.go index b0c9226f..e34737f4 100644 --- a/metal/cli/seo/web.go +++ b/metal/cli/seo/web.go @@ -20,6 +20,6 @@ const WebProjectsUrl = "/projects" const WebResumeName = "Resume" const WebResumeUrl = "/resume" -const Robots = "index, follow" -const ThemeColor = "light dark" -const Description = "Gustavo is a full-stack Software Engineer leader with over two decades of 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 Robots = "index,follow" +const ThemeColor = "#0E172B" +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." From 0452d7f394d565b40df2289ef1022c65808596fb Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 23 Sep 2025 17:05:35 +0800 Subject: [PATCH 17/27] wire manifest + format --- metal/cli/seo/data.go | 46 ++++++++---- metal/cli/seo/{web.go => defaults.go} | 18 +++-- metal/cli/seo/generator.go | 101 +++++++++++++------------- metal/cli/seo/jsonld.go | 4 +- metal/cli/seo/manifest.go | 2 +- metal/cli/seo/stub.html | 38 +++++----- metal/cli/seo/support.go | 18 ++--- metal/env/app.go | 2 +- 8 files changed, 124 insertions(+), 105 deletions(-) rename metal/cli/seo/{web.go => defaults.go} (91%) diff --git a/metal/cli/seo/data.go b/metal/cli/seo/data.go index f9c8fb7a..119a1585 100644 --- a/metal/cli/seo/data.go +++ b/metal/cli/seo/data.go @@ -1,23 +1,37 @@ package seo -import htmltemplate "html/template" +import "html/template" + +type Page struct { + OutputDir string `validate:"required"` + Template *template.Template `validate:"required"` + SiteName string `validate:"required"` + SameAsURL []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"` - 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 htmltemplate.JS `validate:"required"` - OGTagOg TagOgData `validate:"required"` - Twitter TwitterData `validate:"required"` - HrefLang []HrefLangData `validate:"required"` - Favicons []FaviconData `validate:"required"` - Manifest htmltemplate.JS `validate:"required"` - AppleTouchIcon string `validate:"required"` - Categories []string `validate:"required"` - BgColor string `validate:"required"` + 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"` } type TagOgData struct { diff --git a/metal/cli/seo/web.go b/metal/cli/seo/defaults.go similarity index 91% rename from metal/cli/seo/web.go rename to metal/cli/seo/defaults.go index e34737f4..f387493b 100644 --- a/metal/cli/seo/web.go +++ b/metal/cli/seo/defaults.go @@ -1,25 +1,31 @@ package seo -const FoundedYear = 2020 +// --- 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" -const WebHomeName = "Home" +// --- 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" -const WebResumeName = "Resume" -const WebResumeUrl = "/resume" +// --- Web Meta -const Robots = "index,follow" +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 index 7c86b68e..abca2135 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -4,7 +4,7 @@ import ( "bytes" "embed" "fmt" - htmltemplate "html/template" + "html/template" "os" "path/filepath" @@ -19,22 +19,8 @@ import ( //go:embed stub.html var templatesFS embed.FS -type Template struct { - StubPath string `validate:"required,oneof=stub.html"` - OutputDir string `validate:"required"` - Lang string `validate:"required,oneof=en_GB"` - SiteName string `validate:"required"` - SiteURL string `validate:"required,uri"` - LogoURL string `validate:"required,uri"` - SameAsURL []string `validate:"required"` - WebRepoURL string `validate:"required,uri"` - APIRepoURL string `validate:"required,uri"` - AboutPhotoUrl string `validate:"required,uri"` - HTML *htmltemplate.Template `validate:"required"` -} - type Generator struct { - Tmpl Template + Page Page Env *env.Environment Validator *portal.Validator DB *database.Connection @@ -42,35 +28,35 @@ type Generator struct { } func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Validator) (*Generator, error) { - tmpl := Template{ + page := Page{ LogoURL: LogoUrl, + StubPath: StubPath, WebRepoURL: RepoWebUrl, APIRepoURL: RepoApiUrl, - StubPath: "stub.html", SiteURL: env.App.URL, SiteName: env.App.Name, AboutPhotoUrl: AboutPhotoUrl, Lang: env.App.Lang(), OutputDir: env.Seo.SpaDir, - HTML: &htmltemplate.Template{}, + Template: &template.Template{}, SameAsURL: []string{RepoApiUrl, RepoWebUrl, GocantoUrl}, } - if _, err := val.Rejects(tmpl); err != nil { - return nil, fmt.Errorf("invalid template: %w", err) + if _, err := val.Rejects(page); err != nil { + return nil, fmt.Errorf("invalid template state: %s", val.GetErrorsAsJson()) } - if html, err := tmpl.Load(); err != nil { - return nil, fmt.Errorf("there was an issue loading the template [%s]: %w", tmpl.StubPath, err) + if html, err := page.Load(); err != nil { + return nil, fmt.Errorf("could not load initial stub: %w", err) } else { - tmpl.HTML = html + page.Template = html } return &Generator{ DB: db, Env: env, Validator: val, - Tmpl: tmpl, + Page: page, WebsiteRoutes: router.NewWebsiteRoutes(env), }, nil } @@ -119,15 +105,21 @@ func (g *Generator) GenerateHome() error { Projects: projects, } - if err = g.Tmpl.HTML.Execute(&buffer, data); err != nil { + var tData TemplateData + + if tData, err = g.Generate("index.html", data); err != nil { + return fmt.Errorf("home: generating template data: %w", err) + } + + if err = g.Page.Template.Execute(&buffer, tData); err != nil { return fmt.Errorf("home: rendering template: %w", err) } - if err = os.MkdirAll(g.Tmpl.OutputDir, 0o755); err != nil { - return fmt.Errorf("home: creating directory for %s: %w", g.Tmpl.OutputDir, err) + if err = os.MkdirAll(g.Page.OutputDir, 0o755); err != nil { + return fmt.Errorf("home: creating directory for %s: %w", g.Page.OutputDir, err) } - out := filepath.Join(g.Tmpl.OutputDir, "index.html") + out := filepath.Join(g.Page.OutputDir, "index.html") if err = os.WriteFile(out, buffer.Bytes(), 0o644); err != nil { return fmt.Errorf("writing %s: %w", out, err) } @@ -137,21 +129,21 @@ func (g *Generator) GenerateHome() error { return nil } -func (g *Generator) Generate() (TemplateData, error) { +func (g *Generator) Generate(filename string, payload any) (TemplateData, error) { og := TagOgData{ ImageWidth: "600", ImageHeight: "400", Type: "website", - Locale: g.Tmpl.Lang, - ImageAlt: g.Tmpl.SiteName, - SiteName: g.Tmpl.SiteName, - Image: g.Tmpl.AboutPhotoUrl, + Locale: g.Page.Lang, + ImageAlt: g.Page.SiteName, + SiteName: g.Page.SiteName, + Image: g.Page.AboutPhotoUrl, } twitter := TwitterData{ Card: "summary_large_image", - Image: g.Tmpl.AboutPhotoUrl, - ImageAlt: g.Tmpl.SiteName, + Image: g.Page.AboutPhotoUrl, + ImageAlt: g.Page.SiteName, } data := TemplateData{ @@ -159,56 +151,61 @@ func (g *Generator) Generate() (TemplateData, error) { Robots: Robots, Twitter: twitter, ThemeColor: ThemeColor, - Lang: g.Tmpl.Lang, + Lang: g.Page.Lang, Description: Description, - Canonical: g.Tmpl.SiteURL, - AppleTouchIcon: g.Tmpl.LogoURL, - Title: g.Tmpl.SiteName, - JsonLD: NewJsonID(g.Tmpl).Render(), + Canonical: g.Page.SiteURL, + AppleTouchIcon: g.Page.LogoURL, + Title: g.Page.SiteName, + JsonLD: NewJsonID(g.Page).Render(), + BgColor: ThemeColor, Categories: []string{"one", "two"}, //@todo Fetch this! HrefLang: []HrefLangData{ - {Lang: g.Tmpl.Lang, Href: g.Tmpl.SiteURL}, + {Lang: g.Page.Lang, Href: g.Page.SiteURL}, }, Favicons: []FaviconData{ { Rel: "icon", Sizes: "48x48", Type: "image/ico", - Href: g.Tmpl.SiteURL + "/favicon.ico", + Href: g.Page.SiteURL + "/favicon.ico", }, }, } - manifest := NewManifest(g.Tmpl, data) - - data.Manifest = manifest.Render() + data.Manifest = NewManifest(g.Page, data).Render() if _, err := g.Validator.Rejects(og); err != nil { - return TemplateData{}, fmt.Errorf("generate: invalid og data: %w", err) + return TemplateData{}, fmt.Errorf("invalid og data: %s", g.Validator.GetErrorsAsJson()) } if _, err := g.Validator.Rejects(twitter); err != nil { - return TemplateData{}, fmt.Errorf("generate: invalid twitter data: %w", err) + return TemplateData{}, fmt.Errorf("invalid twitter data: %s", g.Validator.GetErrorsAsJson()) } if _, err := g.Validator.Rejects(twitter); err != nil { - return TemplateData{}, fmt.Errorf("generate: invalid twitter data: %w", err) + return TemplateData{}, fmt.Errorf("invalid twitter data: %s", g.Validator.GetErrorsAsJson()) } if _, err := g.Validator.Rejects(data); err != nil { - return TemplateData{}, fmt.Errorf("generate: invalid template data: %w", err) + return TemplateData{}, fmt.Errorf("invalid template data: %s", g.Validator.GetErrorsAsJson()) } return data, nil } -func (t *Template) Load() (*htmltemplate.Template, error) { +func (t *Page) Load() (*template.Template, error) { raw, err := templatesFS.ReadFile(t.StubPath) if err != nil { return nil, fmt.Errorf("reading template: %w", err) } - tmpl, err := htmltemplate.New("seo").Parse(string(raw)) + tmpl, err := template. + New("seo"). + Funcs(template.FuncMap{ + "ManifestDataURL": ManifestDataURL, + }). + Parse(string(raw)) + if err != nil { return nil, fmt.Errorf("parsing template: %w", err) } diff --git a/metal/cli/seo/jsonld.go b/metal/cli/seo/jsonld.go index be21085f..1b1225b6 100644 --- a/metal/cli/seo/jsonld.go +++ b/metal/cli/seo/jsonld.go @@ -24,7 +24,7 @@ type JsonID struct { WebName string } -func NewJsonID(tmpl Template) *JsonID { +func NewJsonID(tmpl Page) *JsonID { return &JsonID{ Lang: tmpl.Lang, SiteURL: tmpl.SiteURL, @@ -131,7 +131,7 @@ func (j *JsonID) Render() template.JS { "@context": "https://schema.org", } - // Encode without HTML escaping and compact. + // Encode without Template escaping and compact. var buf bytes.Buffer enc := json.NewEncoder(&buf) diff --git a/metal/cli/seo/manifest.go b/metal/cli/seo/manifest.go index 9ae5e09c..dfc3ec3d 100644 --- a/metal/cli/seo/manifest.go +++ b/metal/cli/seo/manifest.go @@ -37,7 +37,7 @@ type ManifestShortcut struct { Desc string `json:"description,omitempty"` } -func NewManifest(tmpl Template, data TemplateData) *Manifest { +func NewManifest(tmpl Page, data TemplateData) *Manifest { var icons []ManifestIcon if len(data.Favicons) > 0 { diff --git a/metal/cli/seo/stub.html b/metal/cli/seo/stub.html index b8885485..14c78c3a 100644 --- a/metal/cli/seo/stub.html +++ b/metal/cli/seo/stub.html @@ -5,47 +5,49 @@ + + - - {{- range .HrefLang }} {{- end }} + + {{- range .Favicons }} + + {{- end }} + - - - - - - - - + + + + + + + + - - + + + + + - - {{- range .Favicons }} - - {{- end }} - - diff --git a/metal/cli/seo/support.go b/metal/cli/seo/support.go index f73b9c48..2398240f 100644 --- a/metal/cli/seo/support.go +++ b/metal/cli/seo/support.go @@ -1,21 +1,14 @@ package seo import ( + "encoding/base64" "encoding/json" - "fmt" + "html/template" "net/http/httptest" "github.com/oullin/metal/router" ) -func PrintResponse(rr *httptest.ResponseRecorder) { - fmt.Println("\n--- Captured Response ---") - - fmt.Printf("Status Code: %d\n", rr.Code) - fmt.Printf("Response Body: %s\n", rr.Body.String()) - fmt.Printf("Content-Type Header: %s\n", rr.Header().Get("Content-Type")) -} - func Fetch[T any](response *T, handler func() router.StaticRouteResource) error { req := httptest.NewRequest("GET", "http://localhost:8080/proxy", nil) rr := httptest.NewRecorder() @@ -32,3 +25,10 @@ func Fetch[T any](response *T, handler func() router.StaticRouteResource) error return nil } + +func ManifestDataURL(manifest template.JS) template.URL { + b64 := base64.StdEncoding.EncodeToString([]byte(manifest)) + u := "data:application/manifest+json;base64," + b64 + + return template.URL(u) +} diff --git a/metal/env/app.go b/metal/env/app.go index 1798ae5e..7931a494 100644 --- a/metal/env/app.go +++ b/metal/env/app.go @@ -4,7 +4,7 @@ const local = "local" const staging = "staging" const production = "production" -const defaultLanguage = "en" +const defaultLanguage = "en_GB" type AppEnvironment struct { Name string `validate:"required,min=4"` From f2c0887255c8f596b3a097a1db3ffd5d9c91c8f6 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Wed, 24 Sep 2025 12:31:25 +0800 Subject: [PATCH 18/27] map fixtures in the home template --- .../Sites/oullin/web/public\"/index.html" | 50 ++++++++++++++ metal/cli/seo/data.go | 31 ++++----- metal/cli/seo/generator.go | 65 ++++++++++++------- metal/cli/seo/stub.html | 8 +-- 4 files changed, 112 insertions(+), 42 deletions(-) create mode 100644 "\"/Users/gocanto/Sites/oullin/web/public\"/index.html" diff --git "a/\"/Users/gocanto/Sites/oullin/web/public\"/index.html" "b/\"/Users/gocanto/Sites/oullin/web/public\"/index.html" new file mode 100644 index 00000000..d35e2d78 --- /dev/null +++ "b/\"/Users/gocanto/Sites/oullin/web/public\"/index.html" @@ -0,0 +1,50 @@ + + + + "Gus Blog" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/metal/cli/seo/data.go b/metal/cli/seo/data.go index 119a1585..cecc98f1 100644 --- a/metal/cli/seo/data.go +++ b/metal/cli/seo/data.go @@ -17,21 +17,22 @@ type Page struct { } 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"` + 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 { diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index abca2135..74499c8f 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" @@ -64,50 +65,69 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val func (g *Generator) GenerateHome() error { var err error web := g.WebsiteRoutes - resource := make(map[string]func() router.StaticRouteResource) + var talks payload.TalksResponse + var profile payload.ProfileResponse + var projects payload.ProjectsResponse - resource[router.FixtureProfile] = func() router.StaticRouteResource { + fnProfile := func() router.StaticRouteResource { return handler.MakeProfileHandler(web.Fixture.GetProfileFile()) } - resource[router.FixtureTalks] = func() router.StaticRouteResource { + fnTalks := func() router.StaticRouteResource { return handler.MakeTalksHandler(web.Fixture.GetTalksFile()) } - resource[router.FixtureProjects] = func() router.StaticRouteResource { + fnProjects := func() router.StaticRouteResource { return handler.MakeProjectsHandler(web.Fixture.GetProjectsFile()) } - var talks payload.TalksResponse - var profile payload.ProfileResponse - var projects payload.ProjectsResponse - - if err = Fetch[payload.ProfileResponse](&profile, resource[router.FixtureProfile]); err != nil { + if err = Fetch[payload.ProfileResponse](&profile, fnProfile); err != nil { return fmt.Errorf("home: error fetching profile: %w", err) } - if err = Fetch[payload.TalksResponse](&talks, resource[router.FixtureTalks]); err != nil { + if err = Fetch[payload.TalksResponse](&talks, fnTalks); err != nil { return fmt.Errorf("home: error fetching talks: %w", err) } - if err = Fetch[payload.ProjectsResponse](&projects, resource[router.FixtureProjects]); err != nil { + if err = Fetch[payload.ProjectsResponse](&projects, fnProjects); err != nil { return fmt.Errorf("home: error fetching projects: %w", err) } - var buffer bytes.Buffer - var data = struct { - Talks payload.TalksResponse - Profile payload.ProfileResponse - Projects payload.ProjectsResponse - }{ - Talks: talks, - Profile: profile, - Projects: projects, + var bodyData []template.HTML + + bodyData = append(bodyData, "

Profile

") + bodyData = append(bodyData, template.HTML("

"+profile.Data.Name+","+profile.Data.Profession+"

")) + bodyData = append(bodyData, "

Skills

") + + var itemsA []string + for _, item := range profile.Data.Skills { + itemsA = append(itemsA, "
  • "+item.Item+"
  • ") } + bodyData = append(bodyData, template.HTML("

      "+strings.Join(itemsA, "")+"

    ")) + + bodyData = append(bodyData, "

    Talks

    ") + var itemsB []string + for _, item := range talks.Data { + itemsB = append(itemsB, "
  • "+item.Title+": "+item.Subject+"
  • ") + } + + bodyData = append(bodyData, template.HTML("

      "+strings.Join(itemsB, "")+"

    ")) + + bodyData = append(bodyData, "

    Projects

    ") + var itemsC []string + for _, item := range projects.Data { + itemsC = append(itemsC, "
  • "+item.Title+": "+item.Excerpt+"
  • ") + } + + bodyData = append(bodyData, template.HTML("

      "+strings.Join(itemsC, "")+"

    ")) + + // ----- Template Parsing + var tData TemplateData + var buffer bytes.Buffer - if tData, err = g.Generate("index.html", data); err != nil { + if tData, err = g.Build(bodyData); err != nil { return fmt.Errorf("home: generating template data: %w", err) } @@ -129,7 +149,7 @@ func (g *Generator) GenerateHome() error { return nil } -func (g *Generator) Generate(filename string, payload any) (TemplateData, error) { +func (g *Generator) Build(body []template.HTML) (TemplateData, error) { og := TagOgData{ ImageWidth: "600", ImageHeight: "400", @@ -172,6 +192,7 @@ func (g *Generator) Generate(filename string, payload any) (TemplateData, error) }, } + data.Body = body data.Manifest = NewManifest(g.Page, data).Render() if _, err := g.Validator.Rejects(og); err != nil { diff --git a/metal/cli/seo/stub.html b/metal/cli/seo/stub.html index 14c78c3a..c2f419b1 100644 --- a/metal/cli/seo/stub.html +++ b/metal/cli/seo/stub.html @@ -41,15 +41,13 @@ - - - - - + {{- range .Body }} + {{.}} + {{- end }} From c5f23d805aa3d533ee3a78a7a19083c32943a786 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Wed, 24 Sep 2025 12:33:05 +0800 Subject: [PATCH 19/27] remove this --- .../Sites/oullin/web/public\"/index.html" | 50 ------------------- 1 file changed, 50 deletions(-) delete mode 100644 "\"/Users/gocanto/Sites/oullin/web/public\"/index.html" diff --git "a/\"/Users/gocanto/Sites/oullin/web/public\"/index.html" "b/\"/Users/gocanto/Sites/oullin/web/public\"/index.html" deleted file mode 100644 index d35e2d78..00000000 --- "a/\"/Users/gocanto/Sites/oullin/web/public\"/index.html" +++ /dev/null @@ -1,50 +0,0 @@ - - - - "Gus Blog" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 9669c6ef8cf6970eaca21ec3df5d8d97e3b74902 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Wed, 24 Sep 2025 16:44:09 +0800 Subject: [PATCH 20/27] extract client --- metal/cli/seo/client.go | 82 ++++++++++++++++++++++++++++++++++++++ metal/cli/seo/generator.go | 64 ++++++++++++++--------------- metal/cli/seo/support.go | 21 ---------- 3 files changed, 114 insertions(+), 53 deletions(-) create mode 100644 metal/cli/seo/client.go 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/generator.go b/metal/cli/seo/generator.go index 74499c8f..bbd8691a 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -10,10 +10,10 @@ import ( "strings" "github.com/oullin/database" - "github.com/oullin/handler" "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" ) @@ -22,6 +22,7 @@ var templatesFS embed.FS type Generator struct { Page Page + Client *Client Env *env.Environment Validator *portal.Validator DB *database.Connection @@ -53,44 +54,34 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val page.Template = html } + webRoutes := router.NewWebsiteRoutes(env) + return &Generator{ DB: db, Env: env, Validator: val, Page: page, - WebsiteRoutes: router.NewWebsiteRoutes(env), + WebsiteRoutes: webRoutes, + Client: NewClient(webRoutes), }, nil } func (g *Generator) GenerateHome() error { var err error - web := g.WebsiteRoutes - var talks payload.TalksResponse - var profile payload.ProfileResponse - var projects payload.ProjectsResponse - - fnProfile := func() router.StaticRouteResource { - return handler.MakeProfileHandler(web.Fixture.GetProfileFile()) - } - - fnTalks := func() router.StaticRouteResource { - return handler.MakeTalksHandler(web.Fixture.GetTalksFile()) - } + var talks *payload.TalksResponse + var profile *payload.ProfileResponse + var projects *payload.ProjectsResponse - fnProjects := func() router.StaticRouteResource { - return handler.MakeProjectsHandler(web.Fixture.GetProjectsFile()) + if profile, err = g.Client.GetProfile(); err != nil { + return err } - if err = Fetch[payload.ProfileResponse](&profile, fnProfile); err != nil { - return fmt.Errorf("home: error fetching profile: %w", err) + if talks, err = g.Client.GetTalks(); err != nil { + return err } - if err = Fetch[payload.TalksResponse](&talks, fnTalks); err != nil { - return fmt.Errorf("home: error fetching talks: %w", err) - } - - if err = Fetch[payload.ProjectsResponse](&projects, fnProjects); err != nil { - return fmt.Errorf("home: error fetching projects: %w", err) + if projects, err = g.Client.GetProjects(); err != nil { + return err } var bodyData []template.HTML @@ -125,27 +116,36 @@ func (g *Generator) GenerateHome() error { // ----- Template Parsing var tData TemplateData - var buffer bytes.Buffer - if tData, err = g.Build(bodyData); err != nil { return fmt.Errorf("home: generating template data: %w", err) } - if err = g.Page.Template.Execute(&buffer, tData); err != nil { - return fmt.Errorf("home: rendering template: %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 + + if err = g.Page.Template.Execute(&buffer, data); err != nil { + return fmt.Errorf("%s: rendering template: %w", origin, err) } if err = os.MkdirAll(g.Page.OutputDir, 0o755); err != nil { - return fmt.Errorf("home: creating directory for %s: %w", g.Page.OutputDir, err) + return fmt.Errorf("%s: creating directory for %s: %w", origin, g.Page.OutputDir, err) } out := filepath.Join(g.Page.OutputDir, "index.html") if err = os.WriteFile(out, buffer.Bytes(), 0o644); err != nil { - return fmt.Errorf("writing %s: %w", out, err) + return fmt.Errorf("%s: writing %s: %w", origin, out, err) } - fmt.Println("Home: Done.") - return nil } diff --git a/metal/cli/seo/support.go b/metal/cli/seo/support.go index 2398240f..88a2bd60 100644 --- a/metal/cli/seo/support.go +++ b/metal/cli/seo/support.go @@ -2,30 +2,9 @@ package seo import ( "encoding/base64" - "encoding/json" "html/template" - "net/http/httptest" - - "github.com/oullin/metal/router" ) -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 -} - func ManifestDataURL(manifest template.JS) template.URL { b64 := base64.StdEncoding.EncodeToString([]byte(manifest)) u := "data:application/manifest+json;base64," + b64 From 4514fa60dd6d7a23f7e1f4ebddea171b67ef9196 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Wed, 24 Sep 2025 16:47:17 +0800 Subject: [PATCH 21/27] wip --- metal/cli/main.go | 2 +- metal/cli/seo/generator.go | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/metal/cli/main.go b/metal/cli/main.go index c46ce6ea..bf916e23 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -155,7 +155,7 @@ func generateSEO() error { return err } - if err = gen.GenerateHome(); err != nil { + if err = gen.Generate(); err != nil { return err } diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index bbd8691a..f366082f 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -66,6 +66,16 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val }, 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 From c152b8ae4415712db1fe37a3455d53c3de376430 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Wed, 24 Sep 2025 16:49:43 +0800 Subject: [PATCH 22/27] remove duplication --- metal/cli/seo/generator.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index f366082f..24d92985 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -213,10 +213,6 @@ func (g *Generator) Build(body []template.HTML) (TemplateData, error) { return TemplateData{}, fmt.Errorf("invalid twitter 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()) } From e99b0199452b229b6f32570ce8e2b6f550cdb13c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 25 Sep 2025 10:26:15 +0800 Subject: [PATCH 23/27] scape dynamic data --- metal/cli/seo/data.go | 2 +- metal/cli/seo/generator.go | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/metal/cli/seo/data.go b/metal/cli/seo/data.go index cecc98f1..2a9600d6 100644 --- a/metal/cli/seo/data.go +++ b/metal/cli/seo/data.go @@ -52,7 +52,7 @@ type TwitterData struct { } type HrefLangData struct { - Lang string `validate:"required,oneof=en"` + Lang string `validate:"required,oneof=en_GB"` Href string `validate:"required,url"` } diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 24d92985..8b84196d 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -96,13 +96,21 @@ func (g *Generator) GenerateHome() error { var bodyData []template.HTML - bodyData = append(bodyData, "

    Profile

    ") - bodyData = append(bodyData, template.HTML("

    "+profile.Data.Name+","+profile.Data.Profession+"

    ")) + bodyData = append(bodyData, "

    Profile

    "+ + template.HTML("

    "+ + template.HTMLEscapeString(profile.Data.Name)+","+ + template.HTMLEscapeString(profile.Data.Profession)+ + "

    ", + ), + ) + + //bodyData = append(bodyData, "

    Profile

    ") + //bodyData = append(bodyData, template.HTML("

    "+profile.Data.Name+","+profile.Data.Profession+"

    ")) bodyData = append(bodyData, "

    Skills

    ") var itemsA []string for _, item := range profile.Data.Skills { - itemsA = append(itemsA, "
  • "+item.Item+"
  • ") + itemsA = append(itemsA, "
  • "+template.HTMLEscapeString(item.Item)+"
  • ") } bodyData = append(bodyData, template.HTML("

      "+strings.Join(itemsA, "")+"

    ")) @@ -110,7 +118,7 @@ func (g *Generator) GenerateHome() error { bodyData = append(bodyData, "

    Talks

    ") var itemsB []string for _, item := range talks.Data { - itemsB = append(itemsB, "
  • "+item.Title+": "+item.Subject+"
  • ") + itemsB = append(itemsB, "
  • "+template.HTMLEscapeString(item.Title)+": "+template.HTMLEscapeString(item.Subject)+"
  • ") } bodyData = append(bodyData, template.HTML("

      "+strings.Join(itemsB, "")+"

    ")) @@ -118,7 +126,7 @@ func (g *Generator) GenerateHome() error { bodyData = append(bodyData, "

    Projects

    ") var itemsC []string for _, item := range projects.Data { - itemsC = append(itemsC, "
  • "+item.Title+": "+item.Excerpt+"
  • ") + itemsC = append(itemsC, "
  • "+template.HTMLEscapeString(item.Title)+": "+template.HTMLEscapeString(item.Excerpt)+"
  • ") } bodyData = append(bodyData, template.HTML("

      "+strings.Join(itemsC, "")+"

    ")) From 1fee021ea420fd9b91d055456a9520df7f835ca7 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 25 Sep 2025 11:20:44 +0800 Subject: [PATCH 24/27] extract sections --- metal/cli/seo/generator.go | 53 +++++++-------------------- metal/cli/seo/sections.go | 73 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 41 deletions(-) create mode 100644 metal/cli/seo/sections.go diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 8b84196d..887bec18 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -7,7 +7,6 @@ import ( "html/template" "os" "path/filepath" - "strings" "github.com/oullin/database" "github.com/oullin/handler/payload" @@ -94,47 +93,18 @@ func (g *Generator) GenerateHome() error { return err } - var bodyData []template.HTML + var html []template.HTML + sections := NewSections() - bodyData = append(bodyData, "

    Profile

    "+ - template.HTML("

    "+ - template.HTMLEscapeString(profile.Data.Name)+","+ - template.HTMLEscapeString(profile.Data.Profession)+ - "

    ", - ), - ) - - //bodyData = append(bodyData, "

    Profile

    ") - //bodyData = append(bodyData, template.HTML("

    "+profile.Data.Name+","+profile.Data.Profession+"

    ")) - bodyData = append(bodyData, "

    Skills

    ") - - var itemsA []string - for _, item := range profile.Data.Skills { - itemsA = append(itemsA, "
  • "+template.HTMLEscapeString(item.Item)+"
  • ") - } - - bodyData = append(bodyData, template.HTML("

      "+strings.Join(itemsA, "")+"

    ")) - - bodyData = append(bodyData, "

    Talks

    ") - var itemsB []string - for _, item := range talks.Data { - itemsB = append(itemsB, "
  • "+template.HTMLEscapeString(item.Title)+": "+template.HTMLEscapeString(item.Subject)+"
  • ") - } - - bodyData = append(bodyData, template.HTML("

      "+strings.Join(itemsB, "")+"

    ")) - - bodyData = append(bodyData, "

    Projects

    ") - var itemsC []string - for _, item := range projects.Data { - itemsC = append(itemsC, "
  • "+template.HTMLEscapeString(item.Title)+": "+template.HTMLEscapeString(item.Excerpt)+"
  • ") - } - - bodyData = append(bodyData, template.HTML("

      "+strings.Join(itemsC, "")+"

    ")) + html = append(html, sections.Profile(profile)) + 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(bodyData); err != nil { + if tData, err = g.Build(html); err != nil { return fmt.Errorf("home: generating template data: %w", err) } @@ -150,18 +120,19 @@ func (g *Generator) GenerateHome() error { 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", origin, err) + 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", origin, g.Page.OutputDir, err) + return fmt.Errorf("%s: creating directory for %s: %w", fileName, g.Page.OutputDir, err) } - out := filepath.Join(g.Page.OutputDir, "index.html") + out := filepath.Join(g.Page.OutputDir, fileName) if err = os.WriteFile(out, buffer.Bytes(), 0o644); err != nil { - return fmt.Errorf("%s: writing %s: %w", origin, out, err) + return fmt.Errorf("%s: writing %s: %w", fileName, out, err) } return nil diff --git a/metal/cli/seo/sections.go b/metal/cli/seo/sections.go new file mode 100644 index 00000000..f0562468 --- /dev/null +++ b/metal/cli/seo/sections.go @@ -0,0 +1,73 @@ +package seo + +import ( + "html/template" + "strings" + + "github.com/oullin/handler/payload" +) + +type Sections struct{} + +func NewSections() Sections { + return Sections{} +} + +func (s *Sections) Profile(profile *payload.ProfileResponse) template.HTML { + return "

    Profile

    " + + template.HTML("

    "+ + 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, "
  • "+template.HTMLEscapeString(item.Item)+"
  • ") + } + + return template.HTML("

    Skills

    " + + "

      " + + strings.Join(items, "") + + "

    ", + ) +} + +func (s *Sections) Talks(talks *payload.TalksResponse) template.HTML { + var items []string + + for _, item := range talks.Data { + items = append(items, "
  • "+ + template.HTMLEscapeString(item.Title)+": "+ + template.HTMLEscapeString(item.Subject)+ + "
  • ", + ) + } + + return template.HTML("

    Talks

    " + + "

      " + + strings.Join(items, "") + + "

    ", + ) +} + +func (s *Sections) Projects(projects *payload.ProjectsResponse) template.HTML { + var items []string + + for _, item := range projects.Data { + items = append(items, "
  • "+ + template.HTMLEscapeString(item.Title)+": "+ + template.HTMLEscapeString(item.Excerpt)+ + "
  • ", + ) + } + + return template.HTML("

    Projects

    " + + "

      " + + strings.Join(items, "") + + "

    ", + ) +} From c424c968bbe302d3b46c7883fb2e417a0e17f4da Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 25 Sep 2025 12:11:59 +0800 Subject: [PATCH 25/27] add categories --- database/repository/categories.go | 18 ++++++++++++++- metal/cli/seo/categories.go | 37 +++++++++++++++++++++++++++++++ metal/cli/seo/data.go | 1 + metal/cli/seo/generator.go | 18 +++++++++++---- metal/cli/seo/sections.go | 14 ++++++++++++ 5 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 metal/cli/seo/categories.go 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/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/data.go b/metal/cli/seo/data.go index 2a9600d6..79ef790f 100644 --- a/metal/cli/seo/data.go +++ b/metal/cli/seo/data.go @@ -7,6 +7,7 @@ type Page struct { 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"` diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 887bec18..a38805a9 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -29,11 +29,20 @@ type Generator struct { } 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, @@ -43,11 +52,11 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val SameAsURL: []string{RepoApiUrl, RepoWebUrl, GocantoUrl}, } - if _, err := val.Rejects(page); err != nil { + if _, err = val.Rejects(page); err != nil { return nil, fmt.Errorf("invalid template state: %s", val.GetErrorsAsJson()) } - if html, err := page.Load(); err != nil { + if html, err = page.Load(); err != nil { return nil, fmt.Errorf("could not load initial stub: %w", err) } else { page.Template = html @@ -97,6 +106,7 @@ func (g *Generator) GenerateHome() error { 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)) @@ -160,14 +170,14 @@ func (g *Generator) Build(body []template.HTML) (TemplateData, error) { 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(), - BgColor: ThemeColor, - Categories: []string{"one", "two"}, //@todo Fetch this! HrefLang: []HrefLangData{ {Lang: g.Page.Lang, Href: g.Page.SiteURL}, }, diff --git a/metal/cli/seo/sections.go b/metal/cli/seo/sections.go index f0562468..5b067186 100644 --- a/metal/cli/seo/sections.go +++ b/metal/cli/seo/sections.go @@ -13,6 +13,20 @@ 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(item)+"
  • ") + } + + return template.HTML("

    Categories

    " + + "

      " + + strings.Join(items, "") + + "

    ", + ) +} + func (s *Sections) Profile(profile *payload.ProfileResponse) template.HTML { return "

    Profile

    " + template.HTML("

    "+ From af21658cec8aa427814e4d1e5fd17906ae1deb3d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 25 Sep 2025 12:25:12 +0800 Subject: [PATCH 26/27] wip --- .gitignore | 3 +-- docker-compose.yml | 2 +- metal/kernel/kernel_test.go | 4 ++-- metal/router/fixture.go | 7 ++++--- metal/router/router.go | 1 - pkg/middleware/public_middleware.go | 4 ---- pkg/middleware/token_middleware.go | 9 +-------- storage/seo/pages/.gitkeep | 0 8 files changed, 9 insertions(+), 21 deletions(-) delete mode 100644 storage/seo/pages/.gitkeep diff --git a/.gitignore b/.gitignore index a727b153..12e2403d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,8 @@ tmp database/infra/data # -- [SEO]: static files -storage/seo/pages/*.* +storage/seo/*.* !storage/seo/.gitkeep -!storage/seo/pages/.gitkeep # --- [Caddy]: mtls caddy/mtls/*.* diff --git a/docker-compose.yml b/docker-compose.yml index 1d8a92ce..b76cdb39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,7 +82,7 @@ services: restart: no env_file: - ./.env - image: golang:1.25.1-alpine + image: golang:1.25.1-alpine@sha256:b6ed3fd0452c0e9bcdef5597f29cc1418f61672e9d3a2f55bf02e7222c014abd volumes: - .:/app - go_mod_cache:/go/pkg/mod diff --git a/metal/kernel/kernel_test.go b/metal/kernel/kernel_test.go index 8c1ea7b1..bc68d99a 100644 --- a/metal/kernel/kernel_test.go +++ b/metal/kernel/kernel_test.go @@ -157,7 +157,7 @@ func TestAppBootRoutes(t *testing.T) { t.Fatalf("handler err: %v", err) } - router := router.Router{ + modem := router.Router{ Env: env, Mux: http.NewServeMux(), Pipeline: middleware.Pipeline{ @@ -171,7 +171,7 @@ func TestAppBootRoutes(t *testing.T) { app := &App{} - app.SetRouter(router) + app.SetRouter(modem) app.Boot() diff --git a/metal/router/fixture.go b/metal/router/fixture.go index 4decc88b..8a3e064c 100644 --- a/metal/router/fixture.go +++ b/metal/router/fixture.go @@ -68,10 +68,11 @@ func (f *Fixture) GetRecommendations() *Fixture { } func (f *Fixture) resolveFor(slug string) *Fixture { - f.fullPath = f.getFileFor(slug) - f.file = slug + clone := f + clone.fullPath = clone.getFileFor(slug) + clone.file = slug - return f + return clone } func (f *Fixture) getFileFor(slug string) string { diff --git a/metal/router/router.go b/metal/router/router.go index ed70ce52..5b175f74 100644 --- a/metal/router/router.go +++ b/metal/router/router.go @@ -33,7 +33,6 @@ func (r *Router) PublicPipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerF func (r *Router) PipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc { tokenMiddleware := middleware.MakeTokenMiddleware( - r.Env, r.Pipeline.TokenHandler, r.Pipeline.ApiKeys, ) diff --git a/pkg/middleware/public_middleware.go b/pkg/middleware/public_middleware.go index 864a5b9b..7481b754 100644 --- a/pkg/middleware/public_middleware.go +++ b/pkg/middleware/public_middleware.go @@ -43,10 +43,6 @@ func MakePublicMiddleware(allowedIP string, isProduction bool) PublicMiddleware func (p PublicMiddleware) Handle(next http.ApiHandler) http.ApiHandler { return func(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - if !p.isProduction { - return next(w, r) //@todo remove! - } - if err := p.GuardDependencies(); err != nil { return err } diff --git a/pkg/middleware/token_middleware.go b/pkg/middleware/token_middleware.go index d3e34b3b..ca1f9f2a 100644 --- a/pkg/middleware/token_middleware.go +++ b/pkg/middleware/token_middleware.go @@ -11,7 +11,6 @@ import ( "github.com/oullin/database" "github.com/oullin/database/repository" "github.com/oullin/database/repository/repoentity" - "github.com/oullin/metal/env" "github.com/oullin/pkg/auth" "github.com/oullin/pkg/cache" "github.com/oullin/pkg/http" @@ -31,12 +30,10 @@ type TokenCheckMiddleware struct { TokenHandler *auth.TokenHandler ApiKeys *repository.ApiKeys rateLimiter *limiter.MemoryLimiter - env *env.Environment } -func MakeTokenMiddleware(e *env.Environment, tokenHandler *auth.TokenHandler, apiKeys *repository.ApiKeys) TokenCheckMiddleware { +func MakeTokenMiddleware(tokenHandler *auth.TokenHandler, apiKeys *repository.ApiKeys) TokenCheckMiddleware { return TokenCheckMiddleware{ - env: e, maxFailPerScope: 10, disallowFuture: true, ApiKeys: apiKeys, @@ -52,10 +49,6 @@ func MakeTokenMiddleware(e *env.Environment, tokenHandler *auth.TokenHandler, ap func (t TokenCheckMiddleware) Handle(next http.ApiHandler) http.ApiHandler { return func(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - if t.env != nil && t.env.App.IsLocal() { //@todo remove! - return next(w, r) - } - reqID := strings.TrimSpace(r.Header.Get(portal.RequestIDHeader)) if reqID == "" { diff --git a/storage/seo/pages/.gitkeep b/storage/seo/pages/.gitkeep deleted file mode 100644 index e69de29b..00000000 From d84f432a599a066888bdc345667c766c9a15379d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Thu, 25 Sep 2025 12:31:48 +0800 Subject: [PATCH 27/27] wip --- metal/kernel/kernel_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/metal/kernel/kernel_test.go b/metal/kernel/kernel_test.go index bc68d99a..3230cd2f 100644 --- a/metal/kernel/kernel_test.go +++ b/metal/kernel/kernel_test.go @@ -37,6 +37,8 @@ func validEnvVars(t *testing.T) { t.Setenv("ENV_PUBLIC_ALLOWED_IP", "1.2.3.4") t.Setenv("ENV_PING_USERNAME", "1234567890abcdef") t.Setenv("ENV_PING_PASSWORD", "abcdef1234567890") + t.Setenv("ENV_APP_URL", "http://localhost:8080") + t.Setenv("ENV_SPA_DIR", "/Users/gus/Sites/oullin/web/public/seo") } func TestMakeEnv(t *testing.T) {