cms

package module
v1.2.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 8, 2026 License: MPL-2.0 Imports: 39 Imported by: 0

README

Content Management System

An embeddable content management system for Go web applications.

Starting a new site

cms init writes a runnable site — main.go, a base layout, page templates, .env, and a docker-compose.yml — into an empty directory:

go run github.com/tsawler/cms/cmd/cms@latest init mysite
cd mysite
docker compose up -d      # start the database
go mod tidy
go generate .             # compile static/site.css (needs the tailwindcss CLI)
go run .                  # http://localhost:4000, admin at /admin/

Sign in at /admin/ with the credentials written into .env.

It creates the directory and its go.mod if they do not exist, and pins the cms requirement to the version of the generator you ran, so the generated main.go and the library it compiles against stay in step.

To install it once instead of fetching it each time:

go install github.com/tsawler/cms/cmd/cms@latest
cms init mysite

Useful flags — see cms init -h for the rest:

Flag Default Effect
-db postgres postgres, mysql, or mariadb: picks the driver, the DSN, and the compose service
-name directory name site name in the page title and header
-module directory name module path when init creates the go.mod
-blog=false on leave out the blog, news, and post templates
-tailwind=false on leave out the Tailwind build; supply static/site.css yourself
-captcha off add the Cap and Valkey services for the login CAPTCHA
-replace point go.mod at a local checkout of this module
-tidy off run go mod tidy when finished
-force off overwrite files that already exist
-n off show what would be written, write nothing

Existing files are never overwritten without -force, so re-running init in a project that has moved on only fills in what is missing.

The same generation is available as a library — see scaffold.Write — for hosts that want to wrap it in their own tooling. For the same site built by hand, one step at a time, read QUICKSTART.md.

Documentation

Start here:

Feature guides, in more depth than the quickstart:

Packages

  • admin - Admin interface components
  • auth - Authentication and authorization
  • media - Media library handling
  • render - Template rendering system
  • snippets - Content blocks and section presets
  • content - Page and post content management

Getting Started

// Import the CMS package
import "github.com/tsawler/cms"

// Configure CMS with your database
cfg := cms.Config{
    DB: dbPool,
    TemplateFS: templates,
}

c, err := cms.New(cfg)
if err != nil { 
    // handle error
}

// Run migrations
if err := c.Migrate(ctx); err != nil {
    // handle error
}

See QUICKSTART.md for full details.

Tailwind safelist

Editor content lives in the database, where Tailwind's source scanner can't see it. Every class the CMS's own UI can apply must therefore be listed explicitly, or those styles silently vanish in a production build. This covers the default Styles menu, the alignment and image controls, the snippet library, and the section presets:

/* Tailwind v4 — in assets/input.css, alongside @import "tailwindcss"; */
@source inline("aspect-square aspect-video bg-blue-50 bg-blue-600 bg-blue-700 bg-slate-200 bg-slate-50 bg-slate-900 bg-white bg-yellow-200 border border-2 border-blue-200 border-blue-600 border-dashed border-slate-200 border-slate-300 border-slate-900 columns-1 columns-2 columns-3 flex font-bold font-mono font-semibold font-serif gap-6 gap-8 grid grid-cols-1 h-0.5 hover:bg-blue-600 hover:bg-slate-300 hover:bg-slate-900 hover:text-white inline-block items-center justify-center lg:grid-cols-3 max-w-3xl max-w-5xl max-w-none mb-1 mb-10 mb-2 mb-3 mr-2 mt-1 mt-10 mt-2 mt-3 mt-4 mt-6 mx-auto my-4 my-6 my-8 not-prose object-contain p-4 p-6 prose prose-invert prose-lg prose-slate prose-sm prose-xl px-5 px-6 px-8 py-0 py-12 py-2.5 py-20 py-3 py-6 rounded-2xl rounded-3xl rounded-full rounded-lg rounded-xl size-24 sm:col-span-1 sm:col-span-10 sm:col-span-11 sm:col-span-12 sm:col-span-2 sm:col-span-3 sm:col-span-4 sm:col-span-5 sm:col-span-6 sm:col-span-7 sm:col-span-8 sm:col-span-9 sm:grid-cols-1 sm:grid-cols-12 sm:grid-cols-2 sm:grid-cols-3 sm:grid-cols-4 sm:text-2xl sm:text-3xl sm:text-4xl sm:text-5xl sm:text-7xl text-2xl text-3xl text-4xl text-5xl text-6xl text-blue-600 text-blue-700 text-blue-900 text-center text-emerald-600 text-lg text-red-600 text-right text-slate-200 text-slate-400 text-slate-500 text-slate-600 text-slate-700 text-slate-900 text-sm text-white text-xl tracking-tight tracking-widest uppercase w-10 w-full");

Replacing EditorStyles, Snippets, or SectionStyles with your own? Safelist those classes instead. The Tailwind v3 form of this list, and what to do about classes typed into content after deployment, are in The safelist.

A test in the module (TestDocsListDefaultClasses) checks this list against the defaults, so it cannot quietly fall behind.

Documentation

Overview

Package cms is an embeddable content management system for Go web applications. The host application supplies a database pool (Postgres, MySQL, or MariaDB) and its own page templates; the CMS supplies an admin area, authentication, content storage, and (in later phases) in-place editing, media handling, blog/news, and localization.

Typical use:

c, err := cms.New(cms.Config{DB: pool})
if err != nil { ... }
if err := c.Migrate(ctx); err != nil { ... }

mux.Handle("/admin/", http.StripPrefix("/admin", c.Admin()))
mux.Handle("/", c.Pages())

Index

Constants

View Source
const (
	// MediaAdoptWhenEmpty rebuilds the library from the bucket when the
	// database holds no media. It is the zero value, and so the default.
	MediaAdoptWhenEmpty = media.AdoptWhenEmpty
	// MediaAdoptOff never reads the bucket's manifests.
	MediaAdoptOff = media.AdoptOff
	// MediaAdoptReconcile adopts anything the database is missing on every
	// startup, not only the first.
	MediaAdoptReconcile = media.AdoptReconcile
)

Media adoption modes, aliasing the media package's; see Config.MediaAdopt.

View Source
const DefaultSearchPath = "search"

DefaultSearchPath is where results are served when Config.SearchPath says nothing. A slug, not a path: it is routed exactly like a page, so it picks up locale prefixes for free — /search and /fr/search are the same handler.

View Source
const MinSeedPasswordLength = 8

MinSeedPasswordLength is the shortest password SeedAdmin accepts — the same floor the admin's own password forms enforce, applied here so the account that is created without a form is not the weakest one on the site.

Variables

This section is empty.

Functions

func Version added in v0.9.5

func Version() string

Version reports the version of the cms module compiled into the running binary, as recorded by the Go toolchain at build time:

  • a release tag like "v0.9.4" when the host was built against a published version resolved through go.mod;
  • "(devel)" when the module came from a local checkout instead, e.g. through a go.work workspace or a replace directive, so no tag describes the code;
  • "unknown" when the binary carries no build info at all (test binaries and some non-module builds).

The value comes from debug.ReadBuildInfo, so it is always what the binary was actually built from — there is no constant to bump when tagging a release.

Types

type AdminSection

type AdminSection = admin.Section

AdminSection is one host-registered admin page, mounted inside the admin area's login, session, and CSRF middleware; see admin.Section. Handlers use admin.UserFrom, admin.CSRFToken, admin.SetFlash, and admin.RenderPage to integrate with the admin chrome.

type CMS

type CMS struct {
	// contains filtered or unexported fields
}

CMS is the root object of the module. Create one with New.

func New

func New(cfg Config) (*CMS, error)

New validates cfg, applies defaults, and returns a ready CMS. It does not touch the database; call Migrate before serving requests.

func (*CMS) Admin

func (c *CMS) Admin() http.Handler

Admin returns the handler for the admin area (login, dashboard, user management, and — in later phases — content, media, and settings). Mount it under Config.AdminPath with the prefix stripped:

mux.Handle("/admin/", http.StripPrefix("/admin", c.Admin()))

Most hosts should use Handler instead, which does this wiring itself.

func (*CMS) Close added in v1.2.2

func (c *CMS) Close() error

Close releases what New started in the background: the session store's expiry sweep, and the Redis connection pool when sessions live there. It is safe to call more than once, and safe not to call at all in a process that is about to exit anyway — which is why nothing has needed it until now.

Where it does matter is a process that builds more than one CMS: tests, and hosts that rebuild on reload. Each one used to leave a goroutine ticking against a database it no longer served.

Config.DB is deliberately untouched. The host opened it, may well be using it for its own tables, and closing somebody else's pool is not this function's business.

Requests in flight are not waited for; shut the HTTP server down first if that matters.

func (*CMS) Handler

func (c *CMS) Handler() http.Handler

Handler returns a single handler for the whole site: requests under Config.AdminPath go to the admin area (with the prefix stripped and the bare admin path redirected to its trailing-slash form), and everything else goes to Pages. Because the routing comes from AdminPath, the mount point and the links the admin UI generates can never disagree:

mux.Handle("/", c.Handler())

Hosts that need different wiring — the admin on its own hostname, extra middleware on one side only — can still compose Admin and Pages themselves.

func (*CMS) Lockdown added in v1.1.20

func (c *CMS) Lockdown(next http.Handler, exempt ...string) http.Handler

Lockdown wraps the host's router with the site lock: while the site is locked, every request that is not exempt, not under AdminPath, and not carrying a superadmin's session is refused with 503.

It is not what makes the switch work — Pages enforces the lock on everything the CMS itself serves, so throwing the switch closes the site's pages, feeds, sitemap and media whether or not this is mounted. What Lockdown adds is the two things the CMS cannot do from inside its own handler: closing the host's own routes, and holding an exempt list.

Mount it outermost, in front of the host's own middleware:

return a.logging(cms.Lockdown(router, "/healthz"))

Outermost, because a refused request should cost nothing downstream — no visitor session, no rate-limit bookkeeping, no database write for a page nobody is going to see.

While the site is open the wrapper is a single cached boolean read and a call to next; it adds no query and touches no session. Only a locked site pays for the session lookup, and only on the requests it is about to refuse or let a superadmin through.

What stays reachable

AdminPath and everything under it, always: the login page, the admin, and the admin's own assets. The admin refuses non-superadmins itself.

Each exempt entry is matched against the request path exactly, or as a prefix when it ends in "/". Two kinds of address belong there and almost nothing else does:

  • The endpoints something other than a browser depends on — a container's health check, a payment provider's webhook. A health check answering 503 is an orchestrator killing the site while a superadmin is trying to fix it.

  • Feeds a partner pulls on their own schedule, where being down for an afternoon means listings dropped at the far end and days of re-import to get them back.

An exempt address is served to the public in full while the site is closed. That is the trade: name the ones that must keep answering, and know that each is a window into a site everyone else is told is shut.

Exempting works on CMS addresses too — a feed at /blog/rss.xml, say. Pages would otherwise refuse it on its own account, so a request this wrapper passes is marked as already judged and is not weighed a second time further down.

func (*CMS) MediaManager added in v0.9.0

func (c *CMS) MediaManager() *media.Manager

MediaManager returns the CMS's media library — the same one the admin's Media section manages — for host applications that want to reference library items from their own data: list with All, resolve with GetByID, and build servable URLs with URL. Nil when no object store is configured, so callers must check.

The manager writes only under the library's own key root; host code keeping its own objects elsewhere in the bucket can use both without the two namespaces meeting.

func (*CMS) Migrate

func (c *CMS) Migrate(ctx context.Context) error

Migrate creates or upgrades the CMS's database schema, performs any configured one-time object-store setup (S3Config.ApplyPublicReadPolicy), and rebuilds the media library from the bucket when Config.MediaAdopt calls for it.

It is safe to call on every startup and safe to call from multiple instances concurrently: advisory locks serialize the schema work and the adoption, the bucket policy is idempotent, and adoption skips media the database already has.

func (*CMS) Pages

func (c *CMS) Pages() http.Handler

Pages returns the public site handler: it looks up the page for the request path and renders it with the host's templates. Anonymous visitors get the published version; logged-in CMS users get the draft version with the in-place editor injected. The handler also serves proxied media and the editor script under /cms/. When no TemplateFS is configured it serves a placeholder instead.

While the site lock is on (SiteSettings.Locked, or Config.LockOverride) everything here answers 503 to everyone but a superadmin, so the switch in the editor's Site settings dialog closes the site without the host having to wire anything up. Routes the host serves itself are its own to close: see Lockdown, which also carries the exempt list.

func (*CMS) ReindexSearch added in v1.2.1

func (c *CMS) ReindexSearch(ctx context.Context) (int, error)

ReindexSearch rebuilds the site search index from every published, public page, and reports how many it visited. It is safe to run at any time and safe to run twice.

Ordinary operation never needs it: publishing a page indexes it, and unpublishing or hiding one takes it back out, both inside the transaction that did it. Two moments do — an install that had content before it had a search index, and an upgrade that changes how text is extracted — and neither can be done by a SQL migration, since the extraction is Go.

The first of those is handled without anyone having to know: Handler runs this once, in the background, when the index is empty and the site has published pages. This is exported for the second, and for a host that would rather do it on its own schedule.

func (*CMS) SeedAdmin

func (c *CMS) SeedAdmin(ctx context.Context, email, name, password string) (bool, error)

SeedAdmin creates an initial administrator account if and only if no users exist yet. It returns true if the account was created. Call it after Migrate; it is a no-op on every startup after the first. The account gets the superadmin role — it belongs to whoever set the site up, and further users can be created with lesser roles from the admin area.

A site being set up for the first time is also put into development mode (see content.SiteSettings.Mode), so it is not indexed while it is being built, and has the generated sitemap turned on. The superadmin switches the mode to production when the site is ready to be found. Existing sites are left alone on both counts: they are already live, and having an upgrade quietly pull them out of search results — or claim a URL their own app already answers — would be a far worse surprise than having to flip a switch once.

The password must be at least MinSeedPasswordLength characters. This is the one door into the product that does not go through a form, so it is the one place a site can be given a superadmin nobody chose the password for — an empty string, or whatever a host left in a template. The check happens before the account count, so a misconfigured deployment hears about it on every boot rather than only on the one where it mattered.

func (*CMS) SeedHomePage

func (c *CMS) SeedHomePage(ctx context.Context, templateName, title string) (bool, error)

SeedHomePage creates and publishes a home page — the empty slug, served at "/" — if and only if the site has no pages or posts yet. It returns true if the page was created. Call it after Migrate, alongside SeedAdmin; it is a no-op on every startup after the first, so a home page that is later deleted or renamed stays that way.

templateName must be one of Config.PageTemplates; empty selects the first. This is an argument rather than something the CMS decides because page templates belong to the host — the module ships none, and a page naming a template the host has not configured fails to render.

The page is published rather than left as a draft, so that a fresh install serves something at "/" instead of a 404. It has a title and no content: the point is to give an editor somewhere to click.

func (*CMS) SiteLocked added in v1.1.20

func (c *CMS) SiteLocked(ctx context.Context) bool

SiteLocked reports whether the site is closed to everyone but superadmins — the stored setting, unless Config.LockOverride is set, in which case that wins and the stored value is never consulted.

Hosts rarely need to call this: Lockdown is what enforces it, and the admin is told through its own Deps. It is exported for the host that wants to say so somewhere of its own — a status endpoint, a startup log line, a banner in a section page.

Like every other reading of the site settings it comes from the briefly-cached copy, so a switch takes up to siteModeCacheTTL to reach every instance.

type CaptchaConfig

type CaptchaConfig = captcha.Config

CaptchaConfig locates a self-hosted Cap CAPTCHA server for the admin login form; see captcha.Config.

type Config

type Config struct {
	// DB is the database connection pool. Required. All CMS tables are
	// prefixed cms_, so the pool may point at a database shared with the
	// host application.
	//
	// Open it with a driver matching Dialect: "pgx" (from
	// github.com/jackc/pgx/v5/stdlib) for Postgres, "mysql" (from
	// github.com/go-sql-driver/mysql) for MySQL or MariaDB. A host that
	// already holds a *pgxpool.Pool can convert it with
	// stdlib.OpenDBFromPool.
	DB *sql.DB

	// Dialect selects the SQL the CMS generates: "postgres" (the default),
	// or "mysql" for both MySQL 8.0.31+ and MariaDB 10.6+. It must match the
	// driver DB was opened with — database/sql does not expose that, so it
	// cannot be detected.
	//
	// MySQL DSNs must set parseTime=true so timestamps scan into time.Time;
	// the CMS sets the session time zone to UTC itself.
	Dialect string

	// Locales lists the content locales the site supports, e.g.
	// []string{"en", "fr"}. The first entry is the default. Defaults to
	// []string{"en"}.
	Locales []string

	// AdminPath is the URL prefix the host application mounts Admin()
	// under, used when the admin UI generates links. Defaults to "/admin".
	AdminPath string

	// SiteURL is the site's canonical public base URL, e.g.
	// "https://example.com" — scheme and host, no trailing slash and no
	// path. It is what the CMS uses wherever a link has to work outside
	// this request: the media library's "Copy link" buttons, RSS feed
	// links, hreflang alternates, and password-reset emails.
	//
	// Optional for the first three, which are looked at by the person
	// who asked for them: when empty, each request's own scheme and Host
	// header are used instead, which is right for development and for a
	// site served under one name.
	//
	// Required in production for the fourth. A reset link is read by
	// somebody other than whoever asked for it, so a base taken from the
	// request would let an attacker post the forgot-password form with a
	// Host header of their choosing and have the CMS mail a victim a
	// working reset link pointing at the attacker. Rather than do that,
	// a site with no SiteURL sends no reset email at all and logs why —
	// except when it is being reached over loopback, which is a
	// developer on their own machine. Everything else is unaffected.
	//
	// Set it, too, when the request's host would simply be wrong —
	// behind a proxy that rewrites Host, or when the admin is reached by
	// a different name than the public site.
	//
	// A value with no scheme is assumed to be https.
	SiteURL string

	// LockOverride forces the site lock on or off from configuration,
	// ignoring the stored setting — the escape hatch for a site locked
	// by mistake, or one that has to come up closed. nil, the default,
	// leaves the switch where the admin left it.
	//
	// Set it from the environment with CMS_SITE_LOCKED; see
	// ConfigFromEnv. A false override is the one to reach for in an
	// emergency: it opens the site without a working admin to click
	// through, and clears itself the moment the variable comes back out.
	// Note that the override does not change what is stored, so a site
	// unlocked this way closes again when the variable is removed.
	LockOverride *bool

	// LockedHandler answers requests refused by Lockdown — the page a
	// visitor sees while the site is closed. nil, the default, serves a
	// plain built-in notice.
	//
	// Whatever it writes, Lockdown has already set the status to 503 and
	// the headers that go with it; a handler that writes its own status
	// overrides that, which is almost always a mistake — a closed site
	// answering 200 is a closed site being indexed.
	LockedHandler http.Handler

	// SessionLifetime is how long a login session lasts. Defaults to 24h.
	SessionLifetime time.Duration

	// RememberFor is how long a session lasts when the user ticks
	// "Remember me" at login: the cookie survives browser restarts and
	// the session deadline is extended to this duration. Without the
	// tick, the cookie dies when the browser closes (SessionLifetime
	// still bounds it server-side). Defaults to 30 days.
	RememberFor time.Duration

	// SecureCookies marks the session cookie Secure, so a browser only
	// ever sends it over HTTPS. Setting it forces the flag on.
	//
	// Leaving it alone does not mean "off": a site whose SiteURL is an
	// https:// address is served over HTTPS by the host's own account, so
	// its session cookie is marked Secure whatever this says. That
	// derivation exists because this is the setting whose absence is
	// silent — nothing looks wrong, the site works, and the admin session
	// cookie is one plaintext request away from being readable. A site
	// that has told the CMS it lives at an https:// address should not
	// also have to remember this.
	//
	// So set it only for the case the derivation cannot see: HTTPS in
	// front of an install that leaves SiteURL empty. There is no way to
	// turn it off for an https:// SiteURL, and nothing legitimate wants
	// to — Secure describes the browser's connection to the edge, not the
	// edge's connection to this process, so terminating TLS at a proxy is
	// not a reason to drop it.
	SecureCookies bool

	// ClientIPHeader names the header a trusted reverse proxy sets to the
	// address the request really came from — "X-Forwarded-For",
	// "CF-Connecting-IP", "True-Client-IP". Empty, the default, takes the
	// address off the connection, which is right when the app is reached
	// directly.
	//
	// The login throttle is what reads it, and it is wrong in both
	// directions if this does not match the deployment. Left unset behind
	// a proxy, every visitor arrives from the same address, so the
	// per-source counter stops separating anyone: a few deliberate
	// failures against a known address shut that account for everybody.
	// Set without a proxy that overwrites the header, the value is one
	// the client chooses, and an attacker varies it to draw a fresh
	// allowance for every request.
	//
	// So set it exactly when every request reaches the app through a
	// proxy you control, and that proxy replaces the header rather than
	// appending to whatever arrived. For X-Forwarded-For the rightmost
	// entry is used — the one the nearest proxy appended, and the only
	// one it vouches for.
	ClientIPHeader string

	// Redis moves session storage from the cms_sessions table to Redis.
	// When set, sessions live under cms_session: keys — the prefix keeps
	// them distinct in an instance shared with the host application — and
	// Redis's own key expiry replaces the hourly cleanup sweep. When nil,
	// the default, sessions stay in the database.
	//
	// Nothing else moves: users, content, and settings remain in DB, so
	// Redis here is purely a performance/locality choice for the
	// per-request session lookup. Set it from the environment with
	// CMS_SESSION_REDIS_ADDR; see ConfigFromEnv. Like DB, the connection
	// is not dialed or verified by New; a wrong address surfaces on the
	// first request that touches a session.
	Redis *RedisConfig

	// TemplateFS holds the host application's page templates (often an
	// embed.FS). If nil, the public Pages handler serves a placeholder.
	TemplateFS fs.FS

	// SharedTemplates are glob patterns within TemplateFS for layouts and
	// partials parsed into every page's template set, e.g.
	// []string{"templates/base.gohtml", "templates/partials/*.gohtml"}.
	SharedTemplates []string

	// PageTemplates lists the templates editors may choose for a page.
	// Each entry's File is parsed together with SharedTemplates into its
	// own set, so different pages may define the same block names.
	// An entry with Unlisted set is offered only to superadmins when
	// creating a page — for one-off templates that back a single page.
	PageTemplates []PageTemplate

	// PostsPerPage is how many posts a paginated listing shows on one
	// page — {{cmsFeed "blog"}} and the ?page= links it builds. Zero, the
	// default, uses render.DefaultPostsPerPage (10); negative values are
	// invalid. A listing template can override it per feed with
	// {{cmsFeed "blog" 6}}, and {{cmsPosts}} ignores it entirely, being
	// the unpaginated "newest N" func. Set it from the environment with
	// CMS_POSTS_PER_PAGE; see ConfigFromEnv.
	PostsPerPage int

	// AdminPerPage is how many rows a paginated admin list shows on one
	// page — Blog & News, and Pages. Zero, the default, uses
	// admin.DefaultPerPage (25); negative values are invalid. Set it from
	// the environment with CMS_ADMIN_PER_PAGE; see ConfigFromEnv.
	//
	// It is deliberately separate from PostsPerPage: that one sizes the
	// public listing, where the number is a design decision about the
	// site, and an editor's table wants far more rows than a blog page
	// does. Tuning one must not disturb the other.
	AdminPerPage int

	// AdminMaxRequestBytes caps the body of an unsafe admin request from a
	// signed-in user. Zero, the default, is sized from the media manager's
	// limits; negative values are invalid.
	//
	// Raise it when the admin accepts an upload larger than that — a
	// host section taking many files in one multipart post is the usual
	// reason, since the cap covers the whole body rather than any one
	// file. Requests carrying no session are held to a much smaller fixed
	// ceiling whatever this says, so raising it does not widen what a
	// signed-out caller can send.
	AdminMaxRequestBytes int64

	// PageVersionsKept bounds each page's history: publishing records what
	// went live, and the oldest editions beyond this many are dropped.
	// Zero, the default, keeps content.DefaultVersionsKept of them.
	//
	// A version holds a whole page's published content, so the number is a
	// question about the database rather than about the site — raise it
	// where storage is cheap and an editor may want to reach a long way
	// back, lower it on a site whose pages are large and republished
	// constantly.
	PageVersionsKept int

	// PostTemplate is the page template blog and news posts render with.
	// A post is an ordinary page underneath — its slug lives under blog/
	// or news/ and its body is edited in place like any page, sections
	// and snippets included — so this is parsed like a PageTemplate, but
	// offered only through the Blog & News admin, never in the page
	// template choosers. The template's dot gets a non-nil .Post
	// (render.PostInfo) carrying the post's date, author, and images,
	// and any template may list posts with {{cmsPosts "blog" 10}}. The
	// zero value disables blog & news.
	PostTemplate PageTemplate

	// SearchTemplate is the page template site search results render
	// with, and setting it is what turns site search on: the CMS then
	// answers at SearchPath, and the in-place editor offers the site
	// setting that puts a magnifying glass in {{cmsNav}}.
	//
	// Like PostTemplate it is parsed as a PageTemplate but kept out of
	// the page template choosers — it backs one address, not a page
	// anyone creates. It is handed a synthesized page rather than a
	// stored one, so it should call {{cmsSearch}} for the results,
	// {{cmsSearchForm}} for the box, and {{cmsPagination}} for the rest,
	// and should not declare editable regions: there is no page behind
	// it for an editor to save them to.
	//
	// The zero value disables site search. Content is indexed either way
	// — an index kept only while the feature is on would be wrong the
	// moment it was switched back on — so turning it on later costs
	// nothing but the template.
	SearchTemplate PageTemplate

	// SearchPath is where results are served, as a slug without slashes
	// around it. "" uses DefaultSearchPath ("search"). A page with this
	// slug wins: the CMS answers here only when no page does, so an
	// install that already has a hand-built search page keeps it.
	SearchPath string

	// SearchPerPage is how many results one page of a search shows.
	// Zero, the default, uses render.DefaultSearchPerPage (10).
	SearchPerPage int

	// S3 configures the object store for image uploads. If nil, the
	// media library is disabled.
	S3 *S3Config

	// ObjectStore overrides the S3 object store with a custom
	// implementation (e.g. local disk for development). When set, S3 is
	// ignored.
	ObjectStore media.ObjectStore

	// MediaWebPQuality is the lossy WebP quality, in (0, 1], for the web
	// and thumbnail variants of uploaded images. Zero — the default —
	// uses 0.3, tuned for fast page loads; the untouched original is
	// always stored alongside. Other values are invalid.
	MediaWebPQuality float64

	// MediaMaxVideoMB caps video uploads, in megabytes. Zero — the
	// default — allows 512 MB; negative values are invalid. Images and
	// documents have a fixed 25 MB cap. Videos are stored exactly as
	// uploaded (no transcoding), so the practical ceiling is what
	// visitors' connections can stream.
	MediaMaxVideoMB int

	// MediaAdopt controls whether Migrate rebuilds the media library from
	// the bucket. Every upload writes a manifest describing it, so a
	// bucket carries everything needed to recreate the cms_media rows —
	// filenames, alt text, folders, dimensions and all.
	//
	// The zero value, MediaAdoptWhenEmpty, adopts a bucket's media when
	// the database has none: point a fresh deployment at a bucket that
	// already holds content and the library comes back. That is the case
	// worth having for disaster recovery, and for a staging environment
	// pointed at a copy of production's bucket. Set MediaAdoptReconcile to
	// check on every startup instead, or MediaAdoptOff to never look.
	//
	// Adoption only ever inserts. It will not delete rows whose objects
	// have gone, because every way listing a bucket can fail looks exactly
	// like that.
	MediaAdopt MediaAdoptMode

	// TemplateFuncs are the host application's own template functions,
	// callable from page templates alongside the cms* funcs. Register as
	// many as the site needs; the usual reason is to reach data the CMS
	// does not own — a product catalogue, a vehicle table — from inside a
	// CMS-managed page:
	//
	//	TemplateFuncs: template.FuncMap{
	//	    "featuredVehicles": func(n int) []Vehicle { ... },
	//	    "vehicleCount":     func() int { ... },
	//	}
	//
	// and then, in a page template:
	//
	//	{{range featuredVehicles 3}}<h3>{{.Name}}</h3>{{end}}
	//
	// Templates parse against this map, so it must name every function
	// they call. Names inside the reserved cms* namespace are refused, so
	// a later CMS release can add funcs without breaking a host.
	//
	// The implementations here are used as-is unless RequestFuncs
	// replaces them per request — which is what a function that queries a
	// database usually wants, for the request's context. A function
	// registered only here is shared by every render and must be safe for
	// concurrent use.
	//
	// These functions run inside the page template with the host's full
	// trust: one returning template.HTML bypasses the editor's content
	// sanitizer entirely, so never interpolate untrusted input into it.
	TemplateFuncs template.FuncMap

	// RequestFuncs binds TemplateFuncs to a request. It is called once
	// per page render, and whatever it returns replaces the matching
	// entries in TemplateFuncs for that render alone; names it omits keep
	// their declared implementation. This is where a function gets the
	// request's context for a query it must not outlive, or the URL it
	// needs to read a query parameter:
	//
	//	RequestFuncs: func(r *http.Request) template.FuncMap {
	//	    return template.FuncMap{
	//	        "featuredVehicles": func(n int) []Vehicle {
	//	            return store.Featured(r.Context(), n)
	//	        },
	//	    }
	//	}
	//
	// Only names TemplateFuncs declared can be called — page templates
	// were parsed against that set — so this needs TemplateFuncs to be
	// set, and New refuses the combination that isn't.
	RequestFuncs func(*http.Request) template.FuncMap

	// EditorStyles populates the in-place editor's Styles menu — named,
	// on-brand text styles that apply CSS classes. Nil gets the
	// Tailwind-first defaults (render.DefaultEditorStyles); an empty
	// non-nil slice disables the menu. Classes used here must exist in
	// the site's CSS — with Tailwind, safelist them, since editor
	// content lives in the database where the source scanner can't see
	// it.
	EditorStyles []EditorStyle

	// Snippets are the host application's pre-written HTML blocks for
	// the editor's palette (per-customer components, versioned with the
	// code). Admins can add more in the admin UI; the palette shows
	// both. A snippet with Settings is a section preset — a one-click
	// starting point in the "Add a section" chooser (see
	// snippets.Snippet). An empty non-nil slice ships none. Snippet
	// classes need safelisting like editor styles do.
	//
	// Nil gets everything the module ships (snippets.All), and so does
	// building on top of snippets.All — which is the recommended way to
	// customize, because a release that adds blocks then delivers them
	// on the next rebuild. Nothing here is stored in the database, so
	// there is no seed to re-run and nothing an upgrade can overwrite:
	//
	//	cfg.Snippets = append(snippets.All(), mySnippets()...)
	//
	// Composing from the individual constructors (DefaultSnippets,
	// LibrarySnippets, DefaultSectionPresets, LibrarySectionPresets)
	// still works and is the way to take a deliberate subset — but such
	// a list is a snapshot, and blocks added to a constructor it does
	// not name will not appear.
	Snippets []Snippet

	// SectionStyles are the curated background, width, and rounded-corner
	// options for sections regions ({{cmsSections "name"}}). Nil gets the
	// Tailwind-first defaults (render.DefaultSectionStyles); a config with
	// a nil Corners list gets the default corner options (an empty non-nil
	// slice ships none and hides the setting). The classes need
	// safelisting like editor styles do.
	SectionStyles *SectionStyles

	// Tailwind, when set, makes the CMS rebuild a supplemental
	// stylesheet whenever stored content's class set changes, by running
	// the host's Tailwind CLI over a synthetic file of those classes.
	// Pages then link the result as /cms/content-<hash>.css via
	// {{cmsHead}}, so classes typed into content (e.g. by superadmins in
	// the HTML source view) get real CSS without a site redeploy. Nil
	// disables the feature; see TailwindConfig.
	Tailwind *TailwindConfig

	// Captcha, when set, protects the admin login form with a
	// proof-of-work CAPTCHA verified against a self-hosted Cap server
	// (docker image tiago2/cap). The challenge is solved invisibly in
	// the background by default; set CaptchaConfig.Visible for Cap's
	// checkbox widget. Nil disables the CAPTCHA; the built-in login
	// throttle and honeypot still apply.
	Captcha *CaptchaConfig

	// Mailer, when set, delivers the email the CMS itself originates —
	// today only the password reset the login page's "Forgot your
	// password?" flow sends. The CMS authors the message (subject and
	// both bodies); the host supplies only the transport, which is where
	// delivery policy — SMTP or an API, which From address, a
	// development mail sink — already lives.
	//
	// Nil turns the feature off entirely: the login page shows no
	// forgot-password link and the reset routes answer 404. That is the
	// honest failure mode — a reset form that could never send its link
	// would look broken rather than be off. A host that wants the flow
	// without real delivery (development, tests) can pass a Mailer that
	// logs.
	Mailer Mailer

	// AdminSections are deployment-specific admin pages: each is an
	// http.Handler mounted at {AdminPath}/x/{Path}, behind the admin's
	// login, session, and CSRF middleware, with an optional link in the
	// admin nav. The /x/ namespace guarantees no collision with built-in
	// admin routes, now or after upgrades.
	AdminSections []AdminSection

	// AdminStylesheets are extra stylesheet URLs the admin links on every
	// one of its pages, after its own. A host that registers
	// AdminSections needs this to style the chrome those sections appear
	// in — the count beside their nav link, say — which the admin's
	// strict Content-Security-Policy makes impossible to do inline, and
	// which a stylesheet linked from a section's own page cannot reach,
	// since it is not loaded on the other pages the nav appears on.
	//
	// Each entry is a URL the host serves. An admin section's own asset
	// route is the natural place:
	//
	//	AdminStylesheets: []string{"/admin/x/registrations/adminassets/nav.css"},
	//
	// They are linked last, so their rules win at equal specificity.
	// Nothing validates them beyond the layout escaping the attribute:
	// they are the host's own URLs, not user input.
	AdminStylesheets []string

	// Permissions declares deployment-specific permissions beyond the
	// built-ins (blogs, news, pages, users), for functionality the host
	// gates itself with auth.User.Can — e.g. in-place editing of its own
	// records. Each appears as a grant checkbox on the admin's user form.
	// An AdminSection with a Permission set is declared automatically
	// (labelled with its NavLabel); list it here only to override the
	// label. Grants live in the cms_user_permissions table; admin and
	// superadmin roles hold every permission implicitly.
	Permissions []PermissionDef

	// Logger receives operational log output. Defaults to slog.Default().
	Logger *slog.Logger
}

Config holds everything the host application provides to the CMS.

func ConfigFromEnv

func ConfigFromEnv() (Config, error)

type DashboardCard added in v0.9.0

type DashboardCard = admin.DashboardCard

DashboardCard puts a card for an admin section on the admin dashboard; see admin.DashboardCard and AdminSection.Dashboard.

type EditorStyle

type EditorStyle = render.EditorStyle

EditorStyle is one entry in the in-place editor's Styles menu; see render.EditorStyle.

type Mailer added in v0.9.0

type Mailer = admin.Mailer

Mailer sends the messages the CMS itself originates; see admin.Mailer and Config.Mailer.

type MediaAdoptMode

type MediaAdoptMode = media.AdoptMode

MediaAdoptMode controls rebuilding the media library from the object store; see media.AdoptMode and Config.MediaAdopt.

type PageTemplate

type PageTemplate = render.PageTemplate

PageTemplate is one template the host application offers for pages; see render.PageTemplate.

type PermissionDef added in v0.9.0

type PermissionDef = admin.PermissionDef

PermissionDef declares a deployment-specific permission; see admin.PermissionDef and Config.Permissions.

type RedisConfig added in v0.9.5

type RedisConfig struct {
	// Addr is the server address, host:port, e.g. "localhost:6379".
	Addr string

	// Password authenticates to the server. Leave empty when the server
	// has no AUTH configured.
	Password string

	// DB is the logical database number. The default, 0, is right unless
	// the instance is shared and sessions should live in their own
	// database.
	DB int
}

RedisConfig locates a Redis server for session storage; see Config.Redis.

type S3Config

type S3Config = media.S3Config

S3Config configures the S3-compatible object store for uploads; see media.S3Config.

type SectionOption

type SectionOption = render.SectionOption

SectionOption is one background, width, or corner choice; see render.SectionOption.

type SectionStyles

type SectionStyles = render.SectionStyles

SectionStyles is the curated set of section backgrounds, widths, and corner roundings; see render.SectionStyles.

type Snippet

type Snippet = snippets.Snippet

Snippet is one pre-written HTML block for the editor's palette; see snippets.Snippet.

type TailwindConfig

type TailwindConfig struct {
	// Command is the argv that runs the Tailwind CLI. Two placeholders
	// are replaced before running: {content} becomes the path of a
	// synthetic HTML file holding every class token found in stored
	// content, and {output} the path the command must write CSS to.
	// Both must appear somewhere in the argv. Example, using the
	// standalone binary with a v3 config:
	//
	//	Command: []string{"tailwindcss", "-i", "assets/input.css",
	//	    "-o", "{output}", "--content", "{content}"}
	//
	// Setups whose CLI can't point at an ad-hoc content file (e.g.
	// Tailwind v4 auto-detection) can target a wrapper script that
	// copies {content} where their build expects it.
	Command []string

	// Dir is the working directory for Command — typically where the
	// site's Tailwind config and input stylesheet live. Empty means the
	// process's working directory.
	Dir string

	// Sources fingerprints the files the Tailwind build reads on its own
	// account — page templates, the input stylesheet, a theme file —
	// rather than through {content}.
	//
	// It exists because those files are build inputs the CMS cannot
	// otherwise see. A rebuild is skipped when the class set is
	// unchanged, so without this a template edit that adds a class
	// produces no rebuild at all: the generated stylesheet keeps
	// whatever it had, and because it is linked *after* the site's own
	// stylesheet, the utilities it does carry outrank the ones it does
	// not. The symptom is a responsive class that silently stops
	// applying — a lg: rule losing to the sm: rule the stale artifact
	// still holds — which looks like a CSS bug and is really a cache
	// bug.
	//
	// Nil defaults to Config.TemplateFS, which is right whenever the
	// build scans the same templates the CMS renders — the usual
	// arrangement. Set it explicitly to cover more (an input.css that
	// changes, a theme file) or to opt out with an empty FS.
	//
	// Only the file contents matter, not their paths on disk, so an
	// embed.FS and an os.DirFS both work.
	Sources fs.FS

	// Timeout bounds one rebuild. Zero means 60 seconds.
	Timeout time.Duration
}

TailwindConfig makes the CMS rebuild a supplemental stylesheet for classes that appear in stored content, using the host's own Tailwind setup. Optional: when nil, the CMS never runs a compiler and serves no generated CSS (the safelist documented in the README remains the way to cover the default editor vocabulary).

Directories

Path Synopsis
Package admin serves the CMS admin area: login, dashboard, and user management, with content, media, and settings arriving in later phases.
Package admin serves the CMS admin area: login, dashboard, and user management, with content, media, and settings arriving in later phases.
Package auth provides the CMS's user accounts: password hashing, the Postgres-backed user store, roles, and login throttling.
Package auth provides the CMS's user accounts: password hashing, the Postgres-backed user store, roles, and login throttling.
Package captcha verifies proof-of-work CAPTCHA tokens against a self-hosted Cap server (https://capjs.js.org, docker image tiago2/cap).
Package captcha verifies proof-of-work CAPTCHA tokens against a self-hosted Cap server (https://capjs.js.org, docker image tiago2/cap).
cmd
cms command
Command cms generates a new website built on the cms module.
Command cms generates a new website built on the cms module.
Package content stores the CMS's pages and their editable content (blocks) in Postgres.
Package content stores the CMS's pages and their editable content (blocks) in Postgres.
Package editor ships the in-place editing script injected into pages viewed by logged-in editors, plus a vendored copy of TinyMCE 6.8.6 (the final MIT-licensed release; see tinymce/license.txt) that provides the WYSIWYG behavior for rich HTML regions.
Package editor ships the in-place editing script injected into pages viewed by logged-in editors, plus a vendored copy of TinyMCE 6.8.6 (the final MIT-licensed release; see tinymce/license.txt) that provides the WYSIWYG behavior for rich HTML regions.
examples
basic command
Command basic is the reference host application for the CMS module.
Command basic is the reference host application for the CMS module.
mariadb command
Command mariadb is a second reference host application for the CMS, running on MariaDB instead of Postgres.
Command mariadb is a second reference host application for the CMS, running on MariaDB instead of Postgres.
internal
datefmt
Package datefmt writes dates in the language they are being read in.
Package datefmt writes dates in the language they are being read in.
dberr
Package dberr classifies the database errors the CMS reacts to, across every supported engine.
Package dberr classifies the database errors the CMS reacts to, across every supported engine.
dbtest
Package dbtest runs the CMS's store tests against real database engines in throwaway containers.
Package dbtest runs the CMS's store tests against real database engines in throwaway containers.
dialect
Package dialect isolates the SQL differences between the database engines the CMS supports.
Package dialect isolates the SQL differences between the database engines the CMS supports.
redisstore
Package redisstore implements the scs.Store interface on top of Redis.
Package redisstore implements the scs.Store interface on top of Redis.
sessiondata
Package sessiondata holds the session keys and the small pieces of session plumbing shared across packages: the admin area and the public handler (which needs to recognize logged-in editors for in-place editing), and the two session stores.
Package sessiondata holds the session keys and the small pieces of session plumbing shared across packages: the admin area and the public handler (which needs to recognize logged-in editors for in-place editing), and the two session stores.
sessionstore
Package sessionstore implements the scs.Store interface on top of the cms_sessions table.
Package sessionstore implements the scs.Store interface on top of the cms_sessions table.
sqldb
Package sqldb wraps database/sql with the CMS's dialect translation.
Package sqldb wraps database/sql with the CMS's dialect translation.
Package media stores uploads — images, videos, and documents: the binary objects on any S3-compatible bucket (AWS, Linode, DigitalOcean, MinIO, R2, ...) and their metadata in the database.
Package media stores uploads — images, videos, and documents: the binary objects on any S3-compatible bucket (AWS, Linode, DigitalOcean, MinIO, R2, ...) and their metadata in the database.
Package migrations creates and upgrades the CMS's database schema from SQL files embedded in the module, so host applications need no external migration tool.
Package migrations creates and upgrades the CMS's database schema from SQL files embedded in the module, so host applications need no external migration tool.
Package render executes the host application's Go templates with the CMS's template funcs (cmsText, cmsRegion, cmsHead, cmsScripts) bound to a specific page's content.
Package render executes the host application's Go templates with the CMS's template funcs (cmsText, cmsRegion, cmsHead, cmsScripts) bound to a specific page's content.
Package scaffold writes the starter files for a website built on the cms module — main.go, page templates, .env, docker-compose.yml — into a directory.
Package scaffold writes the starter files for a website built on the cms module — main.go, page templates, .env, docker-compose.yml — into a directory.
Package snippets manages the pre-written HTML blocks editors can insert into rich regions from the in-place editor's palette.
Package snippets manages the pre-written HTML blocks editors can insert into rich regions from the in-place editor's palette.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL