commit 1e7ee2cc1ea99d8362d566fe7c94ad5998dcbc54 Author: Alex Dunmow Date: Wed Jun 3 11:57:17 2026 +0800 feat(gotham): initial extraction from blockninja monorepo diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..73cf751 --- /dev/null +++ b/Makefile @@ -0,0 +1,191 @@ +# Gotham — build & deploy helpers (.so plugin workflow) +# +# The plugin compiles to a .so shared object loaded by the CMS at runtime. +# `make rebuild` copies source to the container, builds the .so, and restarts. +# +# Usage: +# make rebuild # Full rebuild: frontend + .so + CSS + migrations, restart +# make backend # Build .so + migrations, restart +# make build-css # Rebuild Tailwind CSS +# make logs # Tail instance logs +# make status # Show instance container status + +.PHONY: rebuild backend build-frontend build-base-binary build-so copy-plugin-source sync-migrations build-css deploy-css logs status help spinup templ bump-patch bump-minor bump-major sync-version + +# Paths +BLOCKNINJA_DIR := $(HOME)/src/blockninja +PLUGIN_SRC := $(CURDIR) +PLUGIN_NAME := gotham +MIGRATIONS_SRC := $(BLOCKNINJA_DIR)/backend/sql/migrations +GO_BUILDER := localhost/blockninja-go-builder:latest +CONTAINER := instance-gotham +ACCOUNT_SLUG := blockninja +INSTANCE_SLUG := gotham +STYLES_DIR := /var/lib/blockninja/$(ACCOUNT_SLUG)/$(INSTANCE_SLUG)/styles +PLUGIN_DEST := /app/data/plugins/src/$(PLUGIN_NAME) + +# Default target: build the .so locally for development. +all: $(PLUGIN_NAME).so + +# Local plugin build (no container). Useful for CI / quick checks. +$(PLUGIN_NAME).so: $(wildcard *.go) plugin.mod go.mod + CGO_ENABLED=1 go build -buildmode=plugin -ldflags="-s -w" -o $(PLUGIN_NAME).so . + +# Ensure blockninja core services and the instance container are running. +spinup: + $(MAKE) -C $(BLOCKNINJA_DIR) spinup + +# Full rebuild: frontend + .so plugin + CSS + migrations, restart. +rebuild: spinup + $(MAKE) build-frontend + $(MAKE) build-base-binary + $(MAKE) copy-plugin-source + $(MAKE) build-so + $(MAKE) build-css + $(MAKE) sync-migrations + podman restart $(CONTAINER) + @sleep 2 + $(MAKE) deploy-css + @echo "" + @echo "Done. https://$(INSTANCE_SLUG).localdev.blockninjacms.com/" + +# Backend-only rebuild: .so plugin + migrations, restart. +backend: spinup + $(MAKE) build-base-binary + $(MAKE) copy-plugin-source + $(MAKE) build-so + $(MAKE) sync-migrations + podman restart $(CONTAINER) + @echo "Backend updated." + +# Build host admin UI and deploy to container. +build-frontend: + @echo "==> Building @block-ninja/ui ..." + cd $(BLOCKNINJA_DIR)/packages/ui && pnpm run build + @echo "==> Building host admin UI ..." + cd $(BLOCKNINJA_DIR)/web && pnpm run build + @echo "==> Deploying frontend to container ..." + podman exec $(CONTAINER) rm -rf /app/web/dist + podman cp $(BLOCKNINJA_DIR)/web/dist $(CONTAINER):/app/web/dist + @echo "Frontend deployed." + +# Build the base CMS binary (without external plugins) and copy to container. +build-base-binary: + @echo "==> Building base CMS binary ..." + podman run --rm \ + -v $(BLOCKNINJA_DIR)/backend:/src/backend:ro \ + -v blockninja_go_cache:/go/pkg/mod \ + -v /tmp:/out \ + -w /src/backend \ + $(GO_BUILDER) \ + go build -o /out/blockninja-server ./cmd/server + podman cp /tmp/blockninja-server $(CONTAINER):/app/server + rm -f /tmp/blockninja-server + +# Copy plugin source into the container's plugin source directory. +copy-plugin-source: + @echo "==> Copying $(PLUGIN_NAME) source to container ..." + podman exec $(CONTAINER) rm -rf $(PLUGIN_DEST) + podman exec $(CONTAINER) mkdir -p $(PLUGIN_DEST) + podman cp $(PLUGIN_SRC)/. $(CONTAINER):$(PLUGIN_DEST)/ + podman exec $(CONTAINER) rm -rf $(PLUGIN_DEST)/.git $(PLUGIN_DEST)/Makefile + @echo "Plugin source copied." + +# Build the .so using the go-builder container (same toolchain as CMS binary). +# Both builds resolve block/core from the shared module cache — no local replace. +build-so: + @echo "==> Building $(PLUGIN_NAME).so ..." + podman run --rm \ + -v $(PLUGIN_SRC):/src/plugin:ro \ + -v blockninja_go_cache:/go/pkg/mod \ + -v /tmp:/out \ + -w /src/plugin \ + -e CGO_ENABLED=1 \ + $(GO_BUILDER) \ + go build -buildmode=plugin -ldflags="-s -w" -o /out/$(PLUGIN_NAME).so . + podman exec $(CONTAINER) mkdir -p /app/data/plugins/so + podman cp /tmp/$(PLUGIN_NAME).so $(CONTAINER):/app/data/plugins/so/$(PLUGIN_NAME).so + rm -f /tmp/$(PLUGIN_NAME).so + @echo "$(PLUGIN_NAME).so built." + +# Sync base blockninja migration files from host to container. +sync-migrations: + @echo "==> Syncing migrations ..." + @podman unshare bash -c ' \ + M=$$(podman mount $(CONTAINER)) && \ + rm -rf "$$M/app/migrations" && \ + mkdir -p "$$M/app/migrations" && \ + podman umount $(CONTAINER)' + @podman cp $(MIGRATIONS_SRC)/. $(CONTAINER):/app/migrations/ + @echo "Migrations synced." + +# Rebuild Tailwind CSS. +build-css: + @echo "==> Building CSS ..." + cd $(BLOCKNINJA_DIR) && make css + +# Copy built CSS to instance styles dir and container. +deploy-css: + @mkdir -p $(STYLES_DIR) + cp $(BLOCKNINJA_DIR)/data/styles/styles.css $(STYLES_DIR)/styles.css + podman cp $(BLOCKNINJA_DIR)/data/styles/styles.css $(CONTAINER):/app/data/styles/styles.css + podman cp $(BLOCKNINJA_DIR)/styles/input.base.css $(CONTAINER):/app/styles/input.base.css + @echo "CSS deployed." + +# Regenerate templ Go files locally (for development). +templ: + cd $(PLUGIN_SRC) && templ generate + +# Tail instance logs. +logs: + podman logs -f $(CONTAINER) + +# Show instance container status. +status: + @podman inspect $(CONTAINER) --format \ + 'Name: {{.Name}}\nImage: {{.Config.Image}}\nStatus: {{.State.Status}}\nHealth: {{.State.Health.Status}}\nStarted: {{.State.StartedAt}}' \ + 2>/dev/null || echo "Container $(CONTAINER) not found." + +help: + @echo "Targets:" + @echo " all Build $(PLUGIN_NAME).so locally (default)" + @echo " spinup Start blockninja core services + instance container if stopped" + @echo " rebuild Full rebuild: frontend + .so + CSS + migrations, restart" + @echo " backend Build .so + migrations, restart" + @echo " build-frontend Build host admin UI, deploy to container" + @echo " build-base-binary Build base CMS binary, copy to container" + @echo " copy-plugin-source Copy plugin source into container" + @echo " build-so Build .so inside container" + @echo " sync-migrations Copy migration files from host to container" + @echo " build-css Rebuild Tailwind CSS" + @echo " deploy-css Copy CSS to instance styles dir" + @echo " templ Regenerate templ Go files locally" + @echo " logs Tail instance container logs" + @echo " status Show instance container status" + +# --- Version bump targets --- +CURRENT_VERSION := $(shell grep '^version' plugin.mod | sed 's/.*"\(.*\)"/\1/') + +bump-patch: + @NEW=$$(echo $(CURRENT_VERSION) | awk -F. '{printf "%d.%d.%d", $$1, $$2, $$3+1}'); \ + sed -i 's/version = "$(CURRENT_VERSION)"/version = "'$$NEW'"/' plugin.mod; \ + git add plugin.mod && git commit -m "chore: bump version to $$NEW" && git tag "v$$NEW"; \ + echo "Bumped to $$NEW and tagged v$$NEW" + +bump-minor: + @NEW=$$(echo $(CURRENT_VERSION) | awk -F. '{printf "%d.%d.0", $$1, $$2+1}'); \ + sed -i 's/version = "$(CURRENT_VERSION)"/version = "'$$NEW'"/' plugin.mod; \ + git add plugin.mod && git commit -m "chore: bump version to $$NEW" && git tag "v$$NEW"; \ + echo "Bumped to $$NEW and tagged v$$NEW" + +bump-major: + @NEW=$$(echo $(CURRENT_VERSION) | awk -F. '{printf "%d.0.0", $$1+1}'); \ + sed -i 's/version = "$(CURRENT_VERSION)"/version = "'$$NEW'"/' plugin.mod; \ + git add plugin.mod && git commit -m "chore: bump version to $$NEW" && git tag "v$$NEW"; \ + echo "Bumped to $$NEW and tagged v$$NEW" + +sync-version: + @TAG=$$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//'); \ + if [ -z "$$TAG" ]; then echo "No tags found"; exit 1; fi; \ + sed -i 's/version = "$(CURRENT_VERSION)"/version = "'$$TAG'"/' plugin.mod; \ + echo "Synced plugin.mod to $$TAG" diff --git a/README.md b/README.md new file mode 100644 index 0000000..8691645 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Gotham + +Dark, modern BlockNinja theme plugin. Bold typography, high-contrast accents, +and a small set of theme-specific blocks (stats, features, footer) plus +overrides for the built-in `heading` and `text` blocks when the Gotham +template is active. + +## Page templates + +- `default` — header / main / footer +- `landing` — hero / main / cta / footer +- `full-width` — edge-to-edge header / main / footer +- `centered` — narrow centered content for articles and docs + +## Blocks + +- `gotham:stats` — stat row with configurable items +- `gotham:features` — feature grid +- `gotham:footer` — branded footer with optional signup form + +## Build + +``` +make # builds gotham.so locally +make rebuild # rebuilds plugin inside an instance container +``` + +## Versioning + +``` +make bump-patch # 0.1.0 -> 0.1.1 +make bump-minor # 0.1.0 -> 0.2.0 +make bump-major # 0.1.0 -> 1.0.0 +``` + +Then `git push --tags` and `ninja plugin publish` to release. diff --git a/assets/fonts/.gitkeep b/assets/fonts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/fonts/placeholder.txt b/assets/fonts/placeholder.txt new file mode 100644 index 0000000..9dad2fe --- /dev/null +++ b/assets/fonts/placeholder.txt @@ -0,0 +1 @@ +/* placeholder for fonts */ diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..2791c99 --- /dev/null +++ b/assets/style.css @@ -0,0 +1,33 @@ +/* Gotham template styles - uses theme CSS variables */ + +/* Accent color classes - mapped to theme primary */ +.gotham-accent { + color: hsl(var(--primary)); +} + +.gotham-accent-bg { + background-color: hsl(var(--primary)); +} + +.gotham-accent-bg:hover { + background-color: hsl(var(--primary) / 0.9); +} + +/* Border accent */ +.gotham-border-accent { + border-color: hsl(var(--primary)); +} + +/* Additional Gotham-specific utilities */ +.gotham-gradient { + background: linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--card)) 100%); +} + +.gotham-card { + background-color: hsl(var(--card)); + border: 1px solid hsl(var(--border)); +} + +.gotham-glow { + box-shadow: 0 0 20px hsl(var(--primary) / 0.3); +} diff --git a/email_wrapper.templ b/email_wrapper.templ new file mode 100644 index 0000000..6082623 --- /dev/null +++ b/email_wrapper.templ @@ -0,0 +1,203 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "git.dev.alexdunmow.com/block/core/templates" +) + +// GothamEmailWrapper wraps body content in a dark, modern Gotham-branded email template. +func GothamEmailWrapper(body string, emailCtx templates.EmailContext) string { + var buf bytes.Buffer + gothamEmailTemplate(emailCtx, body).Render(context.Background(), &buf) + return buf.String() +} + +// gothamEmailTemplate is the Gotham-branded email template component. +templ gothamEmailTemplate(emailCtx templates.EmailContext, body string) { + + + + + + + + + { emailCtx.SiteSettings.SiteName } + + + + + if emailCtx.PreviewText != "" { +
+ { emailCtx.PreviewText } +
+
+  ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ +
+ } + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + +} + +// Gotham color helper functions - return hex colors with dark theme defaults +func gothamBgColor(emailCtx templates.EmailContext) string { + if emailCtx.Colors.Background != "" { + return emailCtx.Colors.Background + } + return "#0a0a0a" // Near black +} + +func gothamCardColor(emailCtx templates.EmailContext) string { + if emailCtx.Colors.Card != "" { + return emailCtx.Colors.Card + } + return "#141414" // Dark card +} + +func gothamFgColor(emailCtx templates.EmailContext) string { + if emailCtx.Colors.Foreground != "" { + return emailCtx.Colors.Foreground + } + return "#fafafa" // Near white +} + +func gothamPrimaryColor(emailCtx templates.EmailContext) string { + if emailCtx.Colors.Primary != "" { + return emailCtx.Colors.Primary + } + return "#fafafa" // Gotham uses white as primary +} + +func gothamMutedFgColor(emailCtx templates.EmailContext) string { + if emailCtx.Colors.MutedForeground != "" { + return emailCtx.Colors.MutedForeground + } + return "#737373" // Gray +} + +func gothamMutedColor(emailCtx templates.EmailContext) string { + if emailCtx.Colors.Muted != "" { + return emailCtx.Colors.Muted + } + return "#1a1a1a" // Darker muted +} + +func gothamBorderColor(emailCtx templates.EmailContext) string { + if emailCtx.Colors.Border != "" { + return emailCtx.Colors.Border + } + return "#262626" // Dark border +} diff --git a/email_wrapper_templ.go b/email_wrapper_templ.go new file mode 100644 index 0000000..47589a9 --- /dev/null +++ b/email_wrapper_templ.go @@ -0,0 +1,427 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package main + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "bytes" + "context" + "fmt" + "git.dev.alexdunmow.com/block/core/templates" +) + +// GothamEmailWrapper wraps body content in a dark, modern Gotham-branded email template. +func GothamEmailWrapper(body string, emailCtx templates.EmailContext) string { + var buf bytes.Buffer + gothamEmailTemplate(emailCtx, body).Render(context.Background(), &buf) + return buf.String() +} + +// gothamEmailTemplate is the Gotham-branded email template component. +func gothamEmailTemplate(emailCtx templates.EmailContext, body string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.SiteSettings.SiteName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 35, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if emailCtx.PreviewText != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.PreviewText) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 85, Col: 26} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
 ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if emailCtx.SiteSettings.LogoURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if emailCtx.SiteSettings.SiteName != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.SiteSettings.SiteName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 106, Col: 42} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(body).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.SiteSettings.SiteName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 126, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if emailCtx.SiteSettings.SiteURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(emailCtx.SiteSettings.SiteURL) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `email_wrapper.templ`, Line: 131, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if emailCtx.UnsubscribeURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

Unsubscribe

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// Gotham color helper functions - return hex colors with dark theme defaults +func gothamBgColor(emailCtx templates.EmailContext) string { + if emailCtx.Colors.Background != "" { + return emailCtx.Colors.Background + } + return "#0a0a0a" // Near black +} + +func gothamCardColor(emailCtx templates.EmailContext) string { + if emailCtx.Colors.Card != "" { + return emailCtx.Colors.Card + } + return "#141414" // Dark card +} + +func gothamFgColor(emailCtx templates.EmailContext) string { + if emailCtx.Colors.Foreground != "" { + return emailCtx.Colors.Foreground + } + return "#fafafa" // Near white +} + +func gothamPrimaryColor(emailCtx templates.EmailContext) string { + if emailCtx.Colors.Primary != "" { + return emailCtx.Colors.Primary + } + return "#fafafa" // Gotham uses white as primary +} + +func gothamMutedFgColor(emailCtx templates.EmailContext) string { + if emailCtx.Colors.MutedForeground != "" { + return emailCtx.Colors.MutedForeground + } + return "#737373" // Gray +} + +func gothamMutedColor(emailCtx templates.EmailContext) string { + if emailCtx.Colors.Muted != "" { + return emailCtx.Colors.Muted + } + return "#1a1a1a" // Darker muted +} + +func gothamBorderColor(emailCtx templates.EmailContext) string { + if emailCtx.Colors.Border != "" { + return emailCtx.Colors.Border + } + return "#262626" // Dark border +} + +var _ = templruntime.GeneratedTemplate diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..101c4b3 --- /dev/null +++ b/embed.go @@ -0,0 +1,49 @@ +package main + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed assets/* +var assetsFS embed.FS + +//go:embed schemas/* +var schemasFS embed.FS + +//go:embed presets.json +var presetsData []byte + +//go:embed fonts.json +var fontsData []byte + +//go:embed plugin.mod +var pluginModBytes []byte + +// Assets returns the embedded assets filesystem. +func Assets() fs.FS { + sub, _ := fs.Sub(assetsFS, "assets") + return sub +} + +// Schemas returns the embedded schemas filesystem. +func Schemas() fs.FS { + sub, _ := fs.Sub(schemasFS, "schemas") + return sub +} + +// AssetsHandler returns an http.Handler that serves the embedded assets. +func AssetsHandler() http.Handler { + return http.FileServer(http.FS(Assets())) +} + +// ThemePresets returns the embedded theme presets JSON. +func ThemePresets() []byte { + return presetsData +} + +// BundledFonts returns the embedded fonts manifest JSON. +func BundledFonts() []byte { + return fontsData +} diff --git a/features.go b/features.go new file mode 100644 index 0000000..6d9cc79 --- /dev/null +++ b/features.go @@ -0,0 +1,58 @@ +package main + +import ( + "bytes" + "context" + + "git.dev.alexdunmow.com/block/core/blocks" +) + +// FeaturesBlockMeta defines metadata for the features grid block. +var FeaturesBlockMeta = blocks.BlockMeta{ + Key: "features", + Title: "Feature Cards", + Description: "Grid of feature cards with icons and descriptions", + Source: "gotham", +} + +// FeaturesBlock renders a feature cards grid. +// Content expects: {"section_title": "...", "columns": 3, "features": [{"icon": "...", "title": "...", "description": "..."}]} +func FeaturesBlock(ctx context.Context, content map[string]any) string { + items := getSlice(content, "features") + if len(items) == 0 { + return "" + } + + var features []FeatureItem + for _, item := range items { + features = append(features, FeatureItem{ + Icon: getString(item, "icon"), + Title: getString(item, "title"), + Description: getString(item, "description"), + }) + } + + data := FeaturesData{ + SectionTitle: getString(content, "section_title"), + Columns: getInt(content, "columns", 3), + Features: features, + } + + var buf bytes.Buffer + _ = featuresComponent(data).Render(ctx, &buf) + return buf.String() +} + +// FeaturesData contains data for the features component. +type FeaturesData struct { + SectionTitle string + Columns int + Features []FeatureItem +} + +// FeatureItem represents a single feature. +type FeatureItem struct { + Icon string + Title string + Description string +} diff --git a/features.templ b/features.templ new file mode 100644 index 0000000..8275a7b --- /dev/null +++ b/features.templ @@ -0,0 +1,41 @@ +package main + +// featuresComponent renders a Gotham-styled feature cards grid. +templ featuresComponent(data FeaturesData) { +
+
+ if data.SectionTitle != "" { +

{ data.SectionTitle }

+ } +
+ for _, feature := range data.Features { +
+ if feature.Icon != "" { +
+ @iconSVG(feature.Icon) +
+ } +

{ feature.Title }

+ if feature.Description != "" { +

{ feature.Description }

+ } +
+ } +
+
+
+} + +// featureGridCols returns the appropriate grid column class. +func featureGridCols(cols int) string { + switch cols { + case 1: + return "grid-cols-1" + case 2: + return "grid-cols-1 md:grid-cols-2" + case 4: + return "grid-cols-1 md:grid-cols-2 lg:grid-cols-4" + default: + return "grid-cols-1 md:grid-cols-2 lg:grid-cols-3" + } +} diff --git a/features_templ.go b/features_templ.go new file mode 100644 index 0000000..b1a34fd --- /dev/null +++ b/features_templ.go @@ -0,0 +1,160 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package main + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +// featuresComponent renders a Gotham-styled feature cards grid. +func featuresComponent(data FeaturesData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.SectionTitle != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.SectionTitle) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features.templ`, Line: 8, Col: 86} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + var templ_7745c5c3_Var3 = []any{"grid gap-8", featureGridCols(data.Columns)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, feature := range data.Features { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if feature.Icon != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = iconSVG(feature.Icon).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(feature.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features.templ`, Line: 18, Col: 76} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if feature.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(feature.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features.templ`, Line: 20, Col: 77} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// featureGridCols returns the appropriate grid column class. +func featureGridCols(cols int) string { + switch cols { + case 1: + return "grid-cols-1" + case 2: + return "grid-cols-1 md:grid-cols-2" + case 4: + return "grid-cols-1 md:grid-cols-2 lg:grid-cols-4" + default: + return "grid-cols-1 md:grid-cols-2 lg:grid-cols-3" + } +} + +var _ = templruntime.GeneratedTemplate diff --git a/fonts.json b/fonts.json new file mode 100644 index 0000000..b34bed6 --- /dev/null +++ b/fonts.json @@ -0,0 +1,88 @@ +[ + { + "name": "Gotham", + "family": "Gotham", + "variants": [ + { + "weight": "100", + "style": "normal", + "file": "fonts/web/gothamthin-webfont.woff2" + }, + { + "weight": "100", + "style": "italic", + "file": "fonts/web/gothamthinitalic-webfont.woff2" + }, + { + "weight": "200", + "style": "normal", + "file": "fonts/web/gothamxlight-webfont.woff2" + }, + { + "weight": "200", + "style": "italic", + "file": "fonts/web/gothamxlightitalic-webfont.woff2" + }, + { + "weight": "300", + "style": "normal", + "file": "fonts/web/gothamlight-webfont.woff2" + }, + { + "weight": "300", + "style": "italic", + "file": "fonts/web/gothamlightitalic-webfont.woff2" + }, + { + "weight": "400", + "style": "normal", + "file": "fonts/web/gothambook-webfont.woff2" + }, + { + "weight": "400", + "style": "italic", + "file": "fonts/web/gothambookitalic-webfont.woff2" + }, + { + "weight": "500", + "style": "normal", + "file": "fonts/web/gothammedium-webfont.woff2" + }, + { + "weight": "500", + "style": "italic", + "file": "fonts/web/gothammediumitalic-webfont.woff2" + }, + { + "weight": "700", + "style": "normal", + "file": "fonts/web/gothambold-webfont.woff2" + }, + { + "weight": "700", + "style": "italic", + "file": "fonts/web/gothambolditalic-webfont.woff2" + }, + { + "weight": "800", + "style": "normal", + "file": "fonts/web/gothamblack-webfont.woff2" + }, + { + "weight": "800", + "style": "italic", + "file": "fonts/web/gothamblackitalic-webfont.woff2" + }, + { + "weight": "900", + "style": "normal", + "file": "fonts/web/gothamultra-webfont.woff2" + }, + { + "weight": "900", + "style": "italic", + "file": "fonts/web/gothamultraitalic-webfont.woff2" + } + ] + } +] diff --git a/footer.go b/footer.go new file mode 100644 index 0000000..3d4f065 --- /dev/null +++ b/footer.go @@ -0,0 +1,68 @@ +package main + +import ( + "bytes" + "context" + + "git.dev.alexdunmow.com/block/core/blocks" +) + +// FooterBlockMeta defines metadata for the footer block. +var FooterBlockMeta = blocks.BlockMeta{ + Key: "footer", + Title: "Footer", + Description: "Multi-column footer with links and copyright", + Source: "gotham", +} + +// FooterBlock renders a multi-column footer. +// Content expects: {"copyright": "...", "columns": [{"heading": "...", "links": [{"text": "...", "page_id": "...", "url": "..."}]}]} +func FooterBlock(ctx context.Context, content map[string]any) string { + columns := getSlice(content, "columns") + + var footerColumns []FooterColumn + for _, col := range columns { + links := getSlice(col, "links") + var footerLinks []FooterLink + for _, link := range links { + footerLinks = append(footerLinks, FooterLink{ + Text: getString(link, "text"), + PageID: getString(link, "page_id"), + URL: getString(link, "url"), + }) + } + footerColumns = append(footerColumns, FooterColumn{ + Heading: getString(col, "heading"), + Links: footerLinks, + }) + } + + data := FooterData{ + Copyright: getString(content, "copyright"), + Columns: footerColumns, + } + + var buf bytes.Buffer + _ = footerComponent(data).Render(ctx, &buf) + return buf.String() +} + +// FooterData contains data for the footer component. +type FooterData struct { + Copyright string + Columns []FooterColumn +} + +// FooterColumn represents a column in the footer. +type FooterColumn struct { + Heading string + Links []FooterLink +} + +// FooterLink represents a link in the footer. +// If PageID is set, it's an internal page link. Otherwise, URL is used. +type FooterLink struct { + Text string + PageID string + URL string +} diff --git a/footer.templ b/footer.templ new file mode 100644 index 0000000..e1c342b --- /dev/null +++ b/footer.templ @@ -0,0 +1,63 @@ +package main + +// footerComponent renders a Gotham-styled multi-column footer. +// Note: The