go-cms
go-cms is a modular, headless CMS toolkit for Go. It bundles reusable services for content, pages, blocks, widgets, menus, localization, and static generation so you can embed editorial workflows in any Go application.
Table of Contents
Features
- Composable services: opt into content, page, widget, or menu modules independently.
- Storage flexibility: switch between "in memory" or Bun backed SQL repositories without touching application code.
- Localization first: every entity carries locale aware translations and fallbacks.
- Authoring experience: versioning, scheduling, visibility rules, and reusable blocks keep editors productive.
- Static publishing: generate locale aware static bundles or wire services into a dynamic site.
- Observability hooks: structured logging inside commands; optional adapter wiring for telemetry callbacks.
Installation
go get github.com/goliatone/go-cms
Quick Start
package main
import (
"context"
"github.com/goliatone/go-cms"
"github.com/goliatone/go-cms/internal/content"
"github.com/goliatone/go-cms/internal/di"
"github.com/goliatone/go-cms/internal/pages"
"github.com/google/uuid"
)
func main() {
ctx := context.Background()
cfg := cms.DefaultConfig()
cfg.DefaultLocale = "en"
cfg.I18N.Locales = []string{"en", "es"}
container := di.NewContainer(cfg)
contentSvc := container.ContentService()
pageSvc := container.PageService()
authorID := uuid.New()
articleType, err := contentSvc.CreateContentType(ctx, content.CreateContentTypeRequest{
Name: "Article",
Slug: "article",
Schema: map[string]any{
"fields": []map[string]any{
{"name": "title", "type": "string", "required": true},
{"name": "body", "type": "text", "required": true},
},
},
CreatedBy: authorID,
UpdatedBy: authorID,
})
if err != nil {
panic(err)
}
article, err := contentSvc.Create(ctx, content.CreateContentRequest{
ContentTypeID: articleType.ID,
Slug: "hello-world",
Status: "published",
CreatedBy: authorID,
UpdatedBy: authorID,
Translations: []content.ContentTranslationInput{
{
Locale: "en",
Title: "Hello World",
Content: map[string]any{"body": "Content goes here"},
},
},
})
if err != nil {
panic(err)
}
_, err = pageSvc.Create(ctx, pages.CreatePageRequest{
ContentID: article.ID,
Slug: "hello-world",
Status: "published",
CreatedBy: authorID,
UpdatedBy: authorID,
Translations: []pages.PageTranslationInput{
{Locale: "en", Title: "Hello World", Path: "/hello-world"},
},
})
if err != nil {
panic(err)
}
}
See cmd/example/main.go for a more complete walkthrough.
Core Concepts
Content Types & Content
Define schemas that describe editorial data. Content records reference a type and store localized payloads.
contentType, _ := contentSvc.CreateContentType(ctx, content.CreateContentTypeRequest{
Name: "Article",
Slug: "article",
Schema: map[string]any{
"fields": []map[string]any{
{"name": "title", "type": "string", "required": true},
{"name": "body", "type": "text", "required": true},
{"name": "tags", "type": "array"},
},
},
CreatedBy: authorID,
UpdatedBy: authorID,
})
Pages
Pages form the site map. They link to content, choose templates, and emit locale aware routes with SEO metadata.
page, _ := pageSvc.Create(ctx, pages.CreatePageRequest{
ContentID: article.ID,
TemplateID: articleTemplateID,
Slug: "getting-started",
Status: "published",
ParentID: &docsPageID,
CreatedBy: authorID,
UpdatedBy: authorID,
Translations: []pages.PageTranslationInput{
{
Locale: "en",
Title: "Getting Started",
Path: "/docs/getting-started",
MetaDescription: "Learn how to get started",
},
},
})
Blocks
Blocks are reusable fragments that can be attached to pages or content regions with translations.
definition, _ := blockSvc.RegisterDefinition(ctx, blocks.RegisterDefinitionInput{
Name: "call_to_action",
Schema: map[string]any{
"fields": []string{"headline", "description", "button_text", "button_url"},
},
})
instance, _ := blockSvc.CreateInstance(ctx, blocks.CreateInstanceInput{
DefinitionID: definition.ID,
PageID: &page.ID,
Region: "main",
Position: 1,
CreatedBy: authorID,
UpdatedBy: authorID,
})
Widgets add behavioral components with scheduling, visibility rules, and per-area placement.
widgetSvc.RegisterAreaDefinition(ctx, widgets.RegisterAreaDefinitionInput{
Code: "sidebar.primary",
Name: "Primary Sidebar",
Scope: widgets.AreaScopeGlobal,
})
widget, _ := widgetSvc.CreateInstance(ctx, widgets.CreateInstanceInput{
DefinitionID: newsletterWidgetDefID,
Configuration: map[string]any{
"headline": "Stay Updated",
},
VisibilityRules: map[string]any{
"audience": []string{"guest"},
},
CreatedBy: authorID,
UpdatedBy: authorID,
})
widgetSvc.AssignWidgetToArea(ctx, widgets.AssignWidgetToAreaInput{
AreaCode: "sidebar.primary",
InstanceID: widget.ID,
})
Enable builtin definitions and version retention through configuration:
cfg := cms.DefaultConfig()
cfg.Features.Widgets = true
cfg.Widgets.Definitions = []cms.WidgetDefinitionConfig{
{
Name: "promo_banner",
Schema: map[string]any{
"fields": []any{
map[string]any{"name": "headline"},
map[string]any{"name": "cta_text"},
},
},
Defaults: map[string]any{"cta_text": "Sign up"},
Category: "marketing",
},
}
cfg.Features.Versioning = true
cfg.Retention = cms.RetentionConfig{Content: 5, Pages: 3, Blocks: 2}
Menus generate navigation trees with locale aware labels, translation keys, and UI hints for groups/separators/collapsible items.
menu, _ := menuSvc.CreateMenu(ctx, menus.CreateMenuInput{
Code: "primary",
CreatedBy: authorID,
UpdatedBy: authorID,
})
menuSvc.AddMenuItem(ctx, menus.AddMenuItemInput{
MenuID: menu.ID,
Position: 0,
Target: map[string]any{
"type": "page",
"slug": "about",
},
Translations: []menus.MenuItemTranslationInput{
{Locale: "en", Label: "About Us"},
{Locale: "es", Label: "Acerca de"},
},
})
// Section header (non clickable group) with children and label key fallback
group, _ := menuSvc.AddMenuItem(ctx, menus.AddMenuItemInput{
MenuID: menu.ID,
Position: 0,
Type: menus.MenuItemTypeGroup,
Translations: []menus.MenuItemTranslationInput{
{Locale: "en", GroupTitleKey: "menu.group.main"},
},
})
// Clickable item with children (collapsible)
shop, _ := menuSvc.AddMenuItem(ctx, menus.AddMenuItemInput{
MenuID: menu.ID,
ParentID: &group.ID,
Position: 0,
Type: menus.MenuItemTypeItem,
Collapsible: true,
Target: map[string]any{"type": "page", "slug": "shop"},
Translations: []menus.MenuItemTranslationInput{
{Locale: "en", Label: "My Shop"},
},
})
menuSvc.AddMenuItem(ctx, menus.AddMenuItemInput{
MenuID: menu.ID,
ParentID: &shop.ID,
Position: 0,
Type: menus.MenuItemTypeItem,
Target: map[string]any{"type": "page", "slug": "products"},
Translations: []menus.MenuItemTranslationInput{
{Locale: "en", LabelKey: "menu.products"},
},
})
// Separator between groups
menuSvc.AddMenuItem(ctx, menus.AddMenuItemInput{
MenuID: menu.ID,
Position: 2,
Type: menus.MenuItemTypeSeparator,
})
Menu item types:
item (default): clickable row, may have children and optional Collapsible/Collapsed hints.
group: non-clickable header; no target/icon/badge; children only; use GroupTitle/GroupTitleKey for display.
separator: visual divider; no target/children/icon/badge/translations.
Translation precedence: LabelKey (or GroupTitleKey) → translated value → Label/GroupTitle fallback. URL resolution only runs for item types.
Migration note: apply data/sql/migrations/20250209000000_menu_navigation_enhancements.up.sql to add the new menu item/translation columns (type, collapsible flags, metadata, styling, translation keys, group titles). Undo with the matching .down.sql if needed.
Example payload (as served to go-admin):
[
{
"type": "group",
"group_title_key": "menu.group.main",
"children": [
{
"type": "item",
"label": "Home",
"target": {"type": "page", "slug": "home"}
},
{
"type": "item",
"label": "My Shop",
"collapsible": true,
"children": [
{"type": "item", "label_key": "menu.products", "target": {"type": "page", "slug": "products"}},
{"type": "item", "label_key": "menu.orders", "target": {"type": "page", "slug": "orders"}}
]
}
]
},
{"type": "separator"},
{
"type": "group",
"group_title": "Others",
"children": [
{"type": "item", "label": "Promotion", "target": {"type": "page", "slug": "promo"}},
{"type": "item", "label": "Settings", "target": {"type": "page", "slug": "settings"}}
]
}
]
Localization Helpers
Locales, translations, and fallbacks are available across services. cfg.I18N.Locales drives validation, and helpers such as generator.TemplateContext.Helpers.WithBaseURL simplify template routing. Use cfg.I18N.RequireTranslations (defaults to true) to keep the legacy “at least one translation” guard, or flip it to false for staged rollouts; pair it with cfg.I18N.DefaultLocaleRequired when you need to relax the fallback-locale constraint. Both flags are ignored when cfg.I18N.Enabled is false. Every create/update DTO exposes AllowMissingTranslations so workflow transitions or importers can bypass enforcement for a single operation while global defaults remain strict.
Static Site Generation
The generator composes CMS services to emit prerendered HTML, assets, and sitemaps. It honors locale routing, draft visibility, and storage abstractions so you can stream output to disk, S3 compatible buckets, or custom storage backends.
Programmatic usage: import github.com/goliatone/go-cms/pkg/generator (the CLI is a thin wrapper).
package main
import (
"context"
"log"
"github.com/goliatone/go-cms"
"github.com/goliatone/go-cms/pkg/generator"
)
func main() {
cfg := cms.DefaultConfig()
cfg.Generator.Enabled = true
cfg.Generator.OutputDir = "./dist"
cfg.Generator.BaseURL = "https://example.com"
cfg.Generator.Incremental = true
cfg.Generator.CopyAssets = true
module, err := cms.New(cfg)
if err != nil {
log.Fatal(err)
}
gen := generator.NewService(
generator.Config{
OutputDir: cfg.Generator.OutputDir,
BaseURL: cfg.Generator.BaseURL,
Incremental: cfg.Generator.Incremental,
CopyAssets: cfg.Generator.CopyAssets,
GenerateSitemap: cfg.Generator.GenerateSitemap,
DefaultLocale: cfg.I18N.DefaultLocale,
Locales: cfg.I18N.Locales,
},
generator.Dependencies{
Pages: module.Pages(),
Content: module.Content(),
Blocks: module.Blocks(),
Widgets: module.Widgets(),
Menus: module.Menus(),
Themes: module.Themes(),
I18N: module.I18N(),
Renderer: module.Templates(),
Storage: module.Storage(),
Locales: module.I18N(),
Assets: generator.NoOpAssetResolver{}, // inject theme aware resolver in production
Logger: module.Logger(),
Shortcodes: module.Shortcodes(),
},
)
result, err := gen.Build(context.Background(), generator.BuildOptions{})
if err != nil {
log.Fatal(err)
}
log.Printf("built %d pages across %d locales", result.PagesBuilt, len(result.Locales))
}
Contracts:
generator.Service exposing Build, BuildPage, BuildAssets, BuildSitemap, and Clean.
generator.Config/BuildOptions/BuildResult/BuildMetrics for behavior toggles and reporting.
generator.Dependencies to inject CMS services, renderer, storage, logger, optional hooks, and asset resolver (AssetResolver or NoOpAssetResolver).
Templates receive generator.TemplateContext with resolved dependencies:
{{ define "page" }}
<html lang="{{ .Page.Locale.Code }}">
<head>
<title>{{ .Page.Translation.Title }}</title>
<link rel="stylesheet" href="{{ .Helpers.WithBaseURL (.Theme.AssetURL "style") }}">
<style>:root { {{- range $k, $v := .Theme.CSSVars }}{{ $k }}: {{ $v }};{{ end }} }</style>
</head>
<body>
{{ range .Page.Blocks }}{{ template .TemplatePath . }}{{ end }}
{{ range $code, $menu := .Page.Menus }}
{{ template "menu" (dict "code" $code "nodes" $menu) }}
{{ end }}
</body>
</html>
{{ end }}
The Theme block on the context comes from go-theme: configure cfg.Themes.DefaultTheme/DefaultVariant, ship a theme.json alongside your templates/assets, and call helpers such as .Theme.AssetURL, .Theme.Partials, and .Theme.CSSVars (pair them with .Helpers.WithBaseURL to honour your site prefix).
Troubleshooting tips:
static: static command handlers not configured — ensure the generator feature is enabled and that the static command constructors receive the generator service (the provided CLI already injects it); use the adapter submodule only when you need registry/dispatcher/cron wiring.
static: static sitemap handler not configured — enable Config.Generator.GenerateSitemap or provide --output / --base-url.
- Missing telemetry — attach a
ResultCallback that logs or forwards metrics.
- Commands timing out or missing log fields — pass a deadline in the context you supply to
Execute or use the per-command timeout options (for example, staticcmd.BuildSiteWithTimeout); inject a logger provider with di.WithLoggerProvider so commands include operation and domain identifiers in logs.
- Custom storage integration — set
bootstrap.Options.Storage to an implementation of interfaces.StorageProvider.
Markdown Import & Sync
Opt into file based content ingestion without committing to a full static workflow.
cfg := cms.DefaultConfig()
cfg.Features.Markdown = true
cfg.Markdown = cms.MarkdownConfig{
Enabled: true,
ContentDir: "./content",
DefaultLocale: "en",
Locales: []string{"en", "es"},
LocalePatterns: map[string]string{"es": "es/**/*.md"},
Pattern: "**/*.md",
Recursive: true,
}
module, err := cms.New(cfg)
if err != nil {
log.Fatal(err)
}
mdSvc := module.Markdown()
CLI helpers live under cmd/markdown:
# Import a single document without touching pages
go run ./cmd/markdown/import \
--path ./content/en/about.md \
--content-type $CONTENT_TYPE_ID \
--author $AUTHOR_ID
# Sync a directory, updating content and optionally creating pages
go run ./cmd/markdown/sync \
--dir ./content \
--content-type $CONTENT_TYPE_ID \
--author $AUTHOR_ID \
--create-pages \
--template $TEMPLATE_ID \
--update-existing
examples/web/ shows how to wire the markdown service into startup and cron flows. The default adapter currently performs a delete-and-recreate for page updates; swap in an alternative once granular update hooks land in pages.Service.
Configuration
Most features are toggled on the shared configuration struct.
cfg := cms.DefaultConfig()
cfg.DefaultLocale = "en"
cfg.Content.PageHierarchy = true
cfg.I18N.Enabled = true
cfg.I18N.Locales = []string{"en", "es", "fr"}
cfg.Storage.Provider = "bun" // or "memory"
cfg.Cache.Enabled = true
cfg.Cache.DefaultTTL = time.Minute * 5
cfg.Features.Widgets = true
cfg.Navigation.RouteConfig = &urlkit.Config{...}
cfg.Navigation.URLKit.DefaultGroup = "frontend"
cfg.Navigation.URLKit.LocaleGroups = map[string]string{
"es": "frontend.es",
}
cfg.Features.Shortcodes = true
cfg.Shortcodes.Enabled = true
cfg.Shortcodes.Cache.Enabled = true
cfg.Shortcodes.Cache.Provider = "shortcodes" // resolve via di.WithShortcodeCacheProvider
cfg.Markdown.ProcessShortcodes = true
Use di.WithShortcodeCacheProvider to register named cache implementations (Redis, in-memory) for shortcodes and di.WithShortcodeMetrics to feed render telemetry into your monitoring stack.
Activity Hooks
Enable activity emission with cfg.Features.Activity and cfg.Activity.Enabled; set cfg.Activity.Channel to tag events. Inject hooks via di.WithActivityHooks or pass a go-users sink with di.WithActivitySink (internally adapted by pkg/activity/usersink.Hook). Activity events fan out to all hooks and carry verb, actor IDs, object type/ID, channel, and module-specific metadata (slug, status, locale, path, menu code). When no hooks are provided, emissions no-op. In tests, pair activity.CaptureHook with activity.NewEmitter to assert events without persisting them.
Commands & Adapters
- Core commands are plain structs with direct constructors (for example,
staticcmd.NewBuildSiteHandler, markdowncmd.NewSyncDirectoryHandler) that satisfy command.CLICommand/command.CronCommand when exposed via CLI or cron. CLIs in this repo wire those constructors directly; there is no collector or registry inside the core module.
- Cross-cutting concerns live on the structs: each command applies a default timeout (
commands.WithCommandTimeout with commands.DefaultCommandTimeout) and expects a logger from DI. Override the timeout with options such as staticcmd.BuildSiteWithTimeout or pass a logger provider via di.WithLoggerProvider so command logs include operation and domain identifiers.
- To layer telemetry or retries, derive a context with your own deadline, invoke
Execute, and forward the returned error to your monitoring hooks.
- Legacy registry/dispatcher/cron wiring lives in the optional adapter submodule. Install it with
go get github.com/goliatone/go-cms/commands, then call commands.RegisterContainerCommands(container, commands.RegistrationOptions{Dispatcher: ..., Cron: ...}) to rebuild the old flow when migrating hosts.
Managing Storage Profiles at Runtime
Manage storage profiles at runtime through the storage admin service; wire it into your own router or command stack without importing internal/ packages:
module, err := cms.New(cfg)
if err != nil {
log.Fatal(err)
}
storageAdmin := module.StorageAdmin()
profiles, err := storageAdmin.ListProfiles(ctx)
if err != nil {
log.Fatal(err)
}
preview, err := storageAdmin.PreviewProfile(ctx, storage.Profile{
Name: "rotated",
Provider: "bun",
Config: storage.Config{
Name: "rotated",
Driver: "sqlite3",
DSN: "file:/var/lib/cms/rotated.sqlite?_fk=1",
},
})
if err != nil {
log.Fatalf("preview failed: %v", err)
}
log.Printf("provider supports reload=%v", preview.Capabilities.SupportsReload)
err = storageAdmin.ApplyConfig(ctx, cms.StorageConfig{
Profiles: []storage.Profile{
{
Name: "rotated",
Provider: "bun",
Description: "Primary writer",
Default: true,
Config: storage.Config{
Name: "rotated",
Driver: "sqlite3",
DSN: "file:/var/lib/cms/rotated.sqlite?_fk=1",
},
},
},
Aliases: map[string]string{"content": "rotated"},
})
if err != nil {
log.Fatalf("apply config failed: %v", err)
}
- No routes or controllers ship with the module mount these helpers in your own
go-router, chi, gRPC, or command stacks next to the rest of your admin UI.
Schemas() returns JSON schemas for profile/config payloads so UIs can validate forms client side.
- Audit events (
storage_profile_created/updated/deleted) and container logs (storage.profile_activated, storage.profile_activate_failed) provide the telemetry required for the dashboards referenced in TODO_TSK.md.
Workflow Engine Configuration
The workflow subsystem externalises lifecycle decisions so hosts can add review, translation, or bespoke approval steps without touching page services. Enable the default engine or register your own through configuration:
cfg.Workflow.Enabled = true // enable lifecycle orchestration (default)
cfg.Workflow.Provider = "simple" // use the built-in engine
cfg.Workflow.Definitions = []cms.WorkflowDefinitionConfig{
{
Entity: "page",
States: []cms.WorkflowStateConfig{
{Name: "draft", Initial: true},
{Name: "review"},
{Name: "translated"},
{Name: "published", Terminal: true},
},
Transitions: []cms.WorkflowTransitionConfig{
{Name: "submit_review", From: "draft", To: "review"},
{Name: "translate", From: "review", To: "translated"},
{Name: "publish", From: "translated", To: "published"},
},
},
}
When cfg.Workflow.Provider is set to custom, provide an interfaces.WorkflowEngine via di.WithWorkflowEngine during module construction.
To pull definitions from storage, implement interfaces.WorkflowDefinitionStore and pass it to di.WithWorkflowDefinitionStore. Store provided definitions override configuration entries for matching entity types.
engine := myengine.New(customDeps...)
definitions := mystore.NewWorkflowDefinitionStore(db)
container := di.NewContainer(cfg,
di.WithWorkflowEngine(engine),
di.WithWorkflowDefinitionStore(definitions),
)
pageSvc := container.PageService()
For go-command/flow-powered state machines, wrap the external engine with the CMS adapter in internal/workflow/adapter to preserve DTOs, guard hooks, and action-generated events/notifications:
import (
cmsadapter "github.com/goliatone/go-cms/internal/workflow/adapter"
)
flowEngine := buildFlowStateMachine() // engine exposing Transition/AvailableTransitions/RegisterWorkflow
workflowEngine, _ := cmsadapter.NewEngine(flowEngine,
cmsadapter.WithAuthorizer(myAuthorizer{}), // evaluates guard strings on transitions
cmsadapter.WithActionRegistry(cmsadapter.ActionRegistry{
"page::publish": publishAction, // actions can emit events/notifications into TransitionResult
}),
)
cfg.Workflow.Provider = "custom"
container := di.NewContainer(cfg,
di.WithWorkflowEngine(workflowEngine),
)
Additional guides:
- Observability & logging:
docs/LOGGING_GUIDE.md
- Static bootstrapper:
cmd/static/internal/bootstrap
- DI wiring options:
internal/di/options.go
Architecture & Extensibility
internal/
├── content/ # Content entities and content types
├── pages/ # Page hierarchy and routing
├── blocks/ # Reusable content fragments
├── widgets/ # Dynamic behavioral components
├── menus/ # Navigation structures
├── i18n/ # Internationalization helpers
├── adapters/ # Integrations (storage, rendering)
└── di/ # Dependency injection container
pkg/
├── interfaces/ # Public abstractions
└── testsupport/ # Shared fixtures and helpers
- Repository pattern — every module ships "in memory" and Bun backed repositories; the container picks based on
cfg.Storage.Provider.
- Dependency injection —
di.NewContainer wires services. Override dependencies with functional options:
container := di.NewContainer(cfg,
di.WithBunDB(db),
di.WithCache(cache, serializer),
di.WithPageService(customPageSvc),
)
- Commands —
cmd/static and cmd/markdown invoke direct command structs; construct handlers in core or use the adapter module (github.com/goliatone/go-cms/commands) if you need registry/cron wiring.
Database Migrations
When using BunDB as the storage provider, the CMS provides embedded SQL migrations to create all required tables. The migrations follow Bun's naming convention and are embedded in the library binary.
import (
"context"
"database/sql"
"github.com/goliatone/go-cms"
persistence "github.com/goliatone/go-persistence-bun"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/sqlitedialect"
"github.com/uptrace/bun/driver/sqliteshim"
)
// Open database connection
db, err := sql.Open(sqliteshim.ShimName, "file:cms.db?cache=shared")
if err != nil {
panic(err)
}
// Create Bun client with migrations
client, err := persistence.New(cfg.Persistence, db, sqlitedialect.New())
if err != nil {
panic(err)
}
// Register CMS migrations
client.RegisterSQLMigrations(cms.GetMigrationsFS())
// Run migrations
if err := client.Migrate(context.Background()); err != nil {
panic(err)
}
// Check migration status
if report := client.Report(); report != nil && !report.IsZero() {
fmt.Printf("Applied migrations: %s\n", report.String())
}
The CMS includes migrations for all core tables:
- Locales and content types
- Contents with translations and versions
- Themes and templates
- Pages with translations and versions
- Block definitions, instances, translations, and versions
- Widget definitions, instances, translations, areas, and placements
- Menus, menu items, and menu item translations
CLI Reference
# Static generator commands
go run ./cmd/static build --output ./dist --locale en,es
go run ./cmd/static diff --page <page-id> --locale en
go run ./cmd/static build --assets
go run ./cmd/static sitemap
# Markdown import/sync
go run ./cmd/markdown import ...
go run ./cmd/markdown sync ...
# Example application
go run ./cmd/example
go run ./cmd/example shortcodes
Development
# Unit tests
go test ./...
# Package-specific tests
go test ./internal/content/...
go test ./internal/pages/...
go test ./internal/blocks/...
go test ./internal/widgets/...
go test ./internal/menus/...
go test ./internal/generator ./cms
# Coverage
./taskfile dev:cover
# Integration tests (require database)
go test -v ./internal/pages/... -run Integration
Verification
Run the workflow regression suite before shipping workflow changes. These commands exercise the externalized workflow engine (including generator integration) and require the full Go binary path provided in the task plan.
CMS_WORKFLOW_PROVIDER=custom \
CMS_WORKFLOW_ENGINE_ADDR=http://localhost:8080 \
go test ./internal/workflow/... ./internal/integration/...
Translation-related changes should also pass the full suite with the pinned toolchain:
go test ./...
To run the same suite via the task runner:
./taskfile workflow:test
When using the built-in engine, the environment variables can be omitted.
Requirements & Dependencies
- Go 1.24+
- Optional SQL backend supported by uptrace/bun (PostgreSQL, MySQL, SQLite)
Key modules:
Further Reading
- Examples:
cmd/example/main.go, examples/web/
- Logging & observability:
docs/LOGGING_GUIDE.md
- Feature walkthroughs:
docs/FEAT_STATIC.md, docs/FEAT_MARKDOWN.md
- Task-driven design:
docs/CMS_TDD.md, docs/CMD_TDD.md
License
Copyright © 2025 goliatone - Licensed under the terms of LICENSE.