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 }}