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.
- Why go-cms
- Installation
- Quick Start
- Core Concepts
- Static Site Generation
- Markdown Import & Sync
- Configuration
- Architecture & Extensibility
- CLI Reference
- Development
- Requirements & Dependencies
- Further Reading
- 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: locale aware translations, fallbacks, and translation grouping for content/pages.
- Authoring experience: versioning, scheduling, visibility rules, and reusable blocks keep editors productive.
- Menu locations: bind menus to theme-defined locations and resolve navigation by location.
- 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.
go get github.com/goliatone/go-cmspackage 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.
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 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 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.
cms.DefaultConfig() enables order-independent menu upserts (cfg.Menus.AllowOutOfOrderUpserts=true), so modules can insert parents/children in any order and persist collapsible intent before children exist. Set it to false if you want strict validation (missing parents and Collapsible without children will error).
Menus can also be bound to a location string (often a theme menu location code) so templates can resolve navigation without hardcoding menu codes. Theme manifests declare locations via menu_locations (or ThemeConfig.MenuLocations).
menuSvc := module.Menus()
if _, err := menuSvc.UpsertMenuWithLocation(ctx, "primary", "site.primary", nil, authorID); err != nil {
log.Fatal(err)
}
pos0 := 0
pos1 := 1
pos2 := 2
// Menus are addressed by a stable code (e.g. "primary").
// Items are addressed by dot-paths that include the menu code prefix (e.g. "primary.content.pages").
if err := cms.SeedMenu(ctx, cms.SeedMenuOptions{
Menus: menuSvc,
MenuCode: "primary",
Locale: "en",
Actor: authorID,
Items: []cms.SeedMenuItem{
{
Path: "primary.home",
Position: &pos0,
Type: "item",
Target: map[string]any{"type": "url", "url": "/"},
Translations: []cms.MenuItemTranslationInput{
{Locale: "en", Label: "Home"},
{Locale: "es", Label: "Inicio"},
},
},
{
Path: "primary.content",
Position: &pos1,
Type: "group",
Translations: []cms.MenuItemTranslationInput{
{Locale: "en", GroupTitleKey: "menu.group.content"},
},
},
{
Path: "primary.content.pages",
Position: &pos0,
Type: "item",
Target: map[string]any{"type": "url", "url": "/pages"},
Translations: []cms.MenuItemTranslationInput{
{Locale: "en", LabelKey: "menu.pages"},
},
},
{
Path: "primary.separator",
Position: &pos2,
Type: "separator",
},
},
}); err != nil {
log.Fatal(err)
}
navigation, _ := menuSvc.ResolveNavigationByLocation(ctx, "site.primary", "en")
_ = navigationMenu item types:
item(default): clickable row, may have children and optionalCollapsible/Collapsedhints.group: non-clickable header; no target/icon/badge; children only; useGroupTitle/GroupTitleKeyfor display. Groups with no children are still returned when they contain presentation data (label/title/metadata/etc); "empty" groups without meaningful data are omitted.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: menu features rely on migrations:
data/sql/migrations/20250209000000_menu_navigation_enhancements.up.sql(menu item/translation fields: type, collapsible flags, metadata, styling, translation keys, group titles)data/sql/migrations/20250301000000_menu_item_canonical_dedupe.up.sql(canonical key + uniqueness)data/sql/migrations/20251213000000_menu_item_external_parent_refs.up.sql(external_code + parent_ref for out-of-order upserts)data/sql/migrations/20260301000001_menu_locations.up.sql(menus.location + index)
When using BunDB, these migrations are embedded and registered via cms.GetMigrationsFS() (see "Database Migrations").
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.
Translation grouping: content/page translations store TranslationGroupID (backed by translation_group_id in SQL). The services default it to the owning content/page ID and preserve it across updates so export pipelines or translation workflows can treat locales as a single group.
Migration note: data/sql/migrations/20260301000000_translation_grouping.up.sql (content/page translation group columns + indexes).
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.ServiceexposingBuild,BuildPage,BuildAssets,BuildSitemap, andClean.generator.Config/BuildOptions/BuildResult/BuildMetricsfor behavior toggles and reporting.generator.Dependenciesto inject CMS services, renderer, storage, logger, optional hooks, and asset resolver (AssetResolverorNoOpAssetResolver).
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: enableConfig.Generator.GenerateSitemapor provide--output/--base-url.- Missing telemetry: attach a
ResultCallbackthat logs or forwards metrics. - Commands timing out or missing log fields: pass a deadline in the context you supply to
Executeor use the per command timeout options (for example,staticcmd.BuildSiteWithTimeout); inject a logger provider withdi.WithLoggerProviderso commands includeoperationand domain identifiers in logs. - Custom storage integration: set
bootstrap.Options.Storageto an implementation ofinterfaces.StorageProvider.
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-existingexamples/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.
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 = trueUse di.WithShortcodeCacheProvider to register named cache implementations (Redis, in-memory) for shortcodes and di.WithShortcodeMetrics to feed render telemetry into your monitoring stack.
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 noop. In tests, pair activity.CaptureHook with activity.NewEmitter to assert events without persisting them.
- Core commands are plain structs with direct constructors (for example,
staticcmd.NewBuildSiteHandler,markdowncmd.NewSyncDirectoryHandler) that satisfycommand.CLICommand/command.CronCommandwhen 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.WithCommandTimeoutwithcommands.DefaultCommandTimeout) and expects a logger from DI. Override the timeout with options such asstaticcmd.BuildSiteWithTimeoutor pass a logger provider viadi.WithLoggerProviderso command logs includeoperationand 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 callcommands.RegisterContainerCommands(container, commands.RegistrationOptions{Dispatcher: ..., Cron: ...})to rebuild the old flow when migrating hosts.
module, _ := cms.New(cfg)
result, err := commands.RegisterContainerCommands(module.Container(), commands.RegistrationOptions{
Registry: registry, // optional
Dispatcher: dispatcher, // optional
CronRegistrar: cronRegistrar, // optional
LoggerProvider: loggerProvider, // optional
})
_ = result // keep result.Subscriptions for shutdownManage 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 inTODO_TSK.md.
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
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.NewContainerwires services. Override dependencies with functional options:
container := di.NewContainer(cfg,
di.WithBunDB(db),
di.WithCache(cache, serializer),
di.WithPageService(customPageSvc),
)- Commands —
cmd/staticandcmd/markdowninvoke direct command structs; construct handlers in core or use the adapter module (github.com/goliatone/go-cms/commands) if you need registry/cron wiring.
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 include dialect-specific overrides under data/sql/migrations/sqlite, so register them via the dialect-aware loader.
import (
"context"
"database/sql"
"io/fs"
"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 (dialect-aware)
migrationsFS, err := fs.Sub(cms.GetMigrationsFS(), "data/sql/migrations")
if err != nil {
panic(err)
}
client.RegisterDialectMigrations(
migrationsFS,
persistence.WithDialectSourceLabel("data/sql/migrations"),
persistence.WithValidationTargets("postgres", "sqlite"),
)
if err := client.ValidateDialects(context.Background()); err != nil {
panic(err)
}
// 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
# 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 ...
# Translation enforcement toggles (optional; defaults are strict)
go run ./cmd/markdown import --translations-enabled=false --require-translations=false ...
go run ./cmd/static build --translations-enabled=false --require-translations=false ...
# Example application
go run ./cmd/example
go run ./cmd/example shortcodes# 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 IntegrationRun 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:testWhen using the built-in engine, the environment variables can be omitted.
- Go 1.24+
- Optional SQL backend supported by uptrace/bun (PostgreSQL, MySQL, SQLite)
Key modules:
- github.com/uptrace/bun
- github.com/goliatone/go-urlkit
- github.com/goliatone/go-repository-cache
- github.com/google/uuid
- Examples:
cmd/example/main.go,examples/web/ - Logging & observability:
docs/LOGGING_GUIDE.md - Feature walkthroughs:
docs/FEAT_STATIC.md,docs/FEAT_MARKDOWN.md - Menu canonicalization (go-admin alignment):
MENU_CANONICALIZATION.md - Task-driven design:
docs/CMS_TDD.md,docs/CMD_TDD.md
Copyright © 2025 goliatone - Licensed under the terms of LICENSE.