cms

package module
v1.1.15 Latest Latest
Warning

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

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

README

cms

An embeddable content management system for Go web applications. Import it as a module, hand it a database pool, and mount its handlers — no external files, no separate install. See DESIGN.md for the full architecture and build plan.

Runs on PostgreSQL, MySQL 8.0.31+, and MariaDB 10.6+ — see Databases.

Status: phase 6 (blog & news). Auth, user management, page CRUD with draft/publish, public rendering through the host's own templates, the media library (any S3-compatible bucket, automatic image resizing, SVG with script-stripping validation, MP4/WebM video, folders and search, and a self-describing bucket an empty database can rebuild itself from), in-place editing (TinyMCE 6 — the last MIT release, vendored and self-hosted), the curated Styles menu, the snippet palette, editor-composable sections, blog & news posts (page-backed, edited in place, with RSS), and multilingual content (per-locale metadata and blocks, with field-level fallback to the default language) are all working: log in, browse the site, click Edit, change text and images directly on the page, drop in ready-made blocks, save drafts, publish.

The storage layer runs on PostgreSQL, MySQL, and MariaDB, verified by a conformance suite that runs the same store tests against all three.

To make an image editable in place, add data-cms-image to the tag:

<img data-cms-image="hero" src="{{cmsImage "hero"}}" alt="...">

Clicking one while editing raises a small toolbar on the image: a pencil, which opens the media library, and — only once the slot holds a chosen picture — a trash can, which puts it back to whatever the template draws when the slot is empty. There is no gear, because a slot's size, link and alt text belong to the template rather than to the person editing.

A chosen picture arrives at the full-width rendition, which is what a banner across the page wants. A slot smaller than that — a card, a tile three across a grid — asks for a smaller rung by name:

<img data-cms-image="tile-atvs" data-cms-rendition="card"
     src="{{cmsImage "tile-atvs"}}" alt="...">

so the page stores and serves the size it actually displays rather than one the browser has to shrink. The value is a rung of the rendition ladder — web (the default, bounded at 1600px), card (800px) or thumb — and anything the chosen item has no such rendition for, a vector included, falls back to web.

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/

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.

The generated project keeps its theme — fonts to begin with — in assets/theme.css, which both Tailwind builds import. That is the file to edit first, and the generated README.md explains why it is separate; keeping the two builds on one theme below is the short version.

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

To install it once instead of fetching it each time:

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

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 thing done by hand, one step at a time, read QUICKSTART.md.

Quick start

//go:embed templates
var templateFS embed.FS

// Postgres: sql.Open("pgx", dsn), with _ "github.com/jackc/pgx/v5/stdlib".
// MySQL/MariaDB: see Databases below — the DSN needs specific settings.
c, err := cms.New(cms.Config{
    DB:              db, // *sql.DB
    // Dialect:      "mysql", // default "postgres"; "mysql" covers MariaDB
    S3: &cms.S3Config{ // omit to disable the media library
        Endpoint:  "us-ord-10.linodeobjects.com", // any S3-compatible store
        Bucket:    "my-site",
        AccessKey: os.Getenv("S3_ACCESS_KEY"),
        Secret:    os.Getenv("S3_SECRET"),
        // Default: media is proxied through the CMS (/cms/media/…), so a
        // private bucket just works. Set PublicRead or PublicBaseURL to
        // embed direct bucket/CDN URLs instead.
        // KeyPrefix: "my-site", // share one bucket across deployments:
        // each stores its objects under <KeyPrefix>/media/…. Pick a
        // stable slug per deployment and never change it once media
        // exists.
    },
    TemplateFS:      templateFS,
    SharedTemplates: []string{"templates/base.gohtml"},
    PageTemplates: []cms.PageTemplate{
        {File: "templates/pages/home.gohtml", Label: "Home page"},
        {File: "templates/pages/standard.gohtml", Label: "Standard page"},
    },
})
if err != nil { ... }
if err := c.Migrate(ctx); err != nil { ... }          // embedded migrations
if _, err := c.SeedAdmin(ctx, "you@example.com", "You", "a strong password"); err != nil { ... }

// Optional: give a brand-new site a published page at "/" instead of a 404.
// The template must be one of PageTemplates above — page templates belong to
// the host, so the CMS can't pick one, and it refuses a name you haven't
// configured. Pass "" to take the first entry. Both seeds are no-ops once
// the site has any content.
if _, err := c.SeedHomePage(ctx, "templates/pages/home.gohtml", "Welcome"); err != nil { ... }

mux.Handle("/", c.Handler())   // admin under Config.AdminPath, pages everywhere else

(Hosts configured by environment can start from cms.ConfigFromEnv(), which fills S3, CAPTCHA, Tailwind, and the media knobs from the documented variables, and then set DB, TemplateFS, and the rest on the returned Config.)

Templates declare editable areas with the CMS template funcs, and the admin UI discovers them automatically:

<h1>{{cmsText "hero-title"}}</h1>       <!-- short plain text -->
<h1>{{cmsTitle}}</h1>                   <!-- the page's own title, edited in place -->
<div>{{cmsRegion "main"}}</div>         <!-- rich HTML content -->
<div>{{cmsShared "footer"}}</div>       <!-- rich HTML shared by every page -->
<img src="{{cmsImage "hero"}}">         <!-- image from the media library -->
{{cmsSections "body"}}                  <!-- editor-composed full-width sections -->
{{cmsNotice}}                           <!-- optional: place the site-wide notice bar -->
<head> ... {{cmsHead}} ... </head>      <!-- meta description, favicon, robots, per-page CSS -->
... {{cmsScripts}} </body>              <!-- per-page JS -->

Templates can also call the host's own functions, for the parts of a page that come from your tables rather than from an editor — see host data in CMS pages.

Databases

Engine Minimum Driver Config.Dialect
PostgreSQL 12 github.com/jackc/pgx/v5/stdlib ("pgx") "postgres" (default)
MySQL 8.0.31 github.com/go-sql-driver/mysql ("mysql") "mysql"
MariaDB 10.6 github.com/go-sql-driver/mysql ("mysql") "mysql"

Config.DB is a *sql.DB and Config.Dialect must match the driver it was opened with — database/sql does not expose the driver name, so the CMS cannot detect it.

The MySQL floor is 8.0.31 rather than 8.0 because change detection uses EXCEPT, which MySQL only gained in that release. MariaDB has had it since 10.3.

Choosing one

All three are first-class: the same store tests run against all of them, so no feature works on one engine and not another. Pick on operational grounds, not on what the CMS supports.

Match whatever the host application already uses. The CMS prefixes every table cms_, so it is designed to share a database with your own schema. One database means one backup, one connection pool, and transactions that can span both.

Only if you have a free choice:

  • Postgres is the reference implementation — it is what the SQL is written in, and the dialect layer translates away from it. It also keeps DDL inside transactions, so a failed migration rolls back cleanly. On MySQL and MariaDB, DDL commits as it goes and a failed migration can leave a half-applied schema needing manual repair.
  • MySQL / MariaDB if that is what you operate, know, or your host provides. The cost is the four DSN settings below, which are easy to get wrong and fail subtly rather than loudly.

Setup differs in exactly three places — the driver import, the DSN, and Config.Dialect. Nothing else in your application changes.

// Postgres
import _ "github.com/jackc/pgx/v5/stdlib"
db, err := sql.Open("pgx", "postgres://user:pass@localhost:5432/mydb?sslmode=disable")
cfg := cms.Config{DB: db} // Dialect defaults to "postgres"

// MySQL or MariaDB
import _ "github.com/go-sql-driver/mysql"
db, err := sql.Open("mysql", "user:pass@tcp(localhost:3306)/mydb"+
    "?parseTime=true&loc=UTC&time_zone=%27%2B00%3A00%27&clientFoundRows=true")
cfg := cms.Config{DB: db, Dialect: "mysql"}

Switching later means migrating the data yourself: the schemas are equivalent but the CMS has no export/import, so treat it as a real migration rather than a config change.

MySQL and MariaDB DSN settings

Four settings are required; the CMS misbehaves subtly without them:

dsn := "cms:cms@tcp(localhost:3307)/cms" +
    "?parseTime=true&loc=UTC&time_zone=%27%2B00%3A00%27&clientFoundRows=true"
db, err := sql.Open("mysql", dsn)
Setting Why
parseTime=true Timestamps scan into time.Time rather than []byte.
loc=UTC The driver reads and writes timestamps as UTC.
time_zone='+00:00' Pins the server session to UTC too, so SQL now() and Go-written times agree. Without it, session expiry and post dates drift by the server's offset.
clientFoundRows=true Makes UPDATE report rows matched instead of rows changed. Without it, re-saving a record with unchanged values reports zero affected rows and the CMS reads that as "no such row" — saves fail with a not-found error.
Schema

Migrations are embedded per engine under migrations/sql/postgres/ and migrations/sql/mysql/, sharing one version sequence: 0007 is the same change in both. c.Migrate(ctx) picks the right set and is safe to call on every startup.

Adding a migration means writing both files. A unit test fails if a version exists in one directory and not the other.

One operational difference is worth knowing: MySQL and MariaDB commit DDL implicitly. Postgres runs each migration in a transaction and rolls back cleanly on failure; on MySQL a failed migration can leave a partially applied schema that needs manual repair. The version is only recorded on success, so a re-run retries the whole file.

Behaviour parity

The store tests run as a conformance suite against all three engines in throwaway containers (make test, needs Docker; make test-unit skips them). Where the engines would otherwise differ, the CMS pins the behaviour rather than letting it vary:

  • Keyed and enumerated columns are VARCHAR(n) on both, with identical lengths — InnoDB cannot index TEXT without a prefix length, and Postgres migration 0022 narrows the same columns so a value that saves on one engine saves on the other.
  • cms_media_folders.name uses a binary collation on MySQL. Folder names are free text an editor types, and MySQL's default collation would treat "Photos" and "photos" as the same folder where Postgres does not. Slugs and email addresses are normalized before they reach an index, so they need no such pinning.

The Styles menu (Tailwind-first)

The in-place editor's toolbar starts with a Styles dropdown: a built-in Headings submenu (Headings 1–4, plus Paragraph to go back down), followed by a short list of named, on-brand text styles editors can apply to a selection. There is deliberately no free color picker and no font-family menu — every style applies CSS classes, so your stylesheet stays the single source of design truth, and a later redesign restyles existing content by changing the CSS rather than hunting down baked-in inline styles.

Heading 1 is in the submenu for the case that needs it: a region that carries the page's own headline, where the <h1> lives inside the region so an editor can style parts of it. Content under a page title should still start at Heading 2 — the entry exists so a heading that has been flattened to a paragraph can be put back, not as an invitation to put several <h1>s on a page.

The defaults

With no configuration, the menu ships a Tailwind-flavored default set (the five colors fold into a "Color" submenu):

Label Classes Applies to Group
Muted text-slate-500 selection Color
Red text-red-600 selection Color
Green text-emerald-600 selection Color
Blue text-blue-600 selection Color
White text-white selection Color
Highlight bg-yellow-200 selection
Serif font-serif selection
Monospace font-mono selection
Lead paragraph text-lg text-slate-600 whole <p>
Small print text-sm text-slate-500 selection
Safelist the classes (important)

Editor content lives in the database, and production Tailwind only generates CSS for classes it finds while scanning your source files — so every class the menu can apply must be safelisted, or applied styles will silently not render in production. The toolbar's alignment buttons also apply utility classes — text-left/center/right on blocks, and float-left mr-6, block mx-auto, or float-right ml-6 on images — as does the image gear's display-width and roundness settings (w-full, w-2/3, w-1/2, w-1/3, h-auto, rounded-lg, rounded-2xl, rounded-full), the video slot's generated players (w-full, rounded-lg, and aspect-video on YouTube/Vimeo embeds), and the toolbar's table button (w-full on the table, border-b-2 border-slate-300 p-2 text-left font-semibold on header cells, border-b border-slate-200 p-2 align-top on body cells, and the table gear's variants: border, p-1, p-4, w-auto, odd:bg-slate-50; each table is also wrapped in a <div class="cms-table-wrap overflow-x-auto"> so wide tables scroll in place on phones instead of widening the page) — so safelist those regardless of which Styles menu you ship. (The gear's Shadow presets apply the CMS's own cms-shadow-subtle/cms-shadow-strong classes, styled by CSS that {{cmsHead}} ships — no safelisting needed, and overridable by your stylesheet.) For the default menu plus alignment and the image gear:

// tailwind.config.js (Tailwind v3)
safelist: [
    "text-slate-500", "text-red-600", "text-emerald-600",
    "text-blue-600", "text-white", "bg-yellow-200", "font-serif",
    "font-mono", "text-lg", "text-slate-600", "text-sm",
    "text-left", "text-center", "text-right",
    "float-left", "float-right", "mr-6", "ml-6", "block", "mx-auto",
    "w-full", "w-2/3", "w-1/2", "w-1/3", "h-auto",
    "rounded-lg", "rounded-2xl", "rounded-full", "aspect-video",
    "border", "border-b", "border-b-2", "border-slate-200", "border-slate-300",
    "p-1", "p-2", "p-4", "align-top", "font-semibold", "w-auto",
    "odd:bg-slate-50", "overflow-x-auto", "object-cover",
],
/* Tailwind v4: in your main CSS file */
@source inline("text-slate-500 text-red-600 text-emerald-600 text-blue-600 text-white bg-yellow-200 font-serif font-mono text-lg text-slate-600 text-sm text-left text-center text-right float-left float-right mr-6 ml-6 block mx-auto w-full w-2/3 w-1/2 w-1/3 h-auto rounded-lg rounded-2xl rounded-full aspect-video object-cover");

(The example site shows the full production pattern with the Tailwind v4 standalone CLI: a compiled site stylesheet built from the templates plus this safelist — examples/basic/assets/input.css, regenerated with go generate . — and the CMS's generated content stylesheet for classes that live only in the database, wired through examples/basic/tailwind-content.sh; see the next section.)

Generated CSS for content classes (optional)

The safelist covers the classes the CMS's own UI can emit — but users with the superadmin role can type any class into content through the HTML source views, and no static safelist can cover that. Setting Config.Tailwind closes the gap: after every content change the CMS collects the class tokens from stored content and, when the set actually changed, runs your Tailwind CLI over a synthetic file of those classes and serves the result as a supplemental stylesheet (/cms/content-<hash>.css, linked by {{cmsHead}}, where the hash is of the generated CSS itself and so doubles as the cache buster). The stylesheet is stored in the database, so every instance of a multi-instance deployment serves the same artifact.

Tailwind: &cms.TailwindConfig{
    // {content} = the synthetic class file the CMS writes;
    // {output} = where the command must write CSS.
    Command: []string{"tailwindcss", "-i", "assets/input.css",
        "-o", "{output}", "--content", "{content}"},
    Dir: "/path/to/site", // where your Tailwind config lives
    // Files your build scans for itself, so editing one triggers a
    // rebuild. Defaults to Config.TemplateFS; see below.
    Sources: os.DirFS("/path/to/site/templates"),
},

The command runs your Tailwind — your version, your plugins, and your theme as far as the input CSS you point it at declares one. Builds are asynchronous, serialized, and skipped when nothing that feeds them has changed; a failed build logs and keeps the previous stylesheet. Setups whose CLI can't take an ad-hoc content file (e.g. Tailwind v4 auto-detection) can point Command at a wrapper script that copies {content} where their build expects it.

"Nothing that feeds them" includes the files your build scans on its own account. If your input CSS has @source "../templates" — which the section below explains why you want — then a template is a build input the CMS cannot see, and an edit to one must invalidate the artifact. Sources is how it does: its contents are fingerprinted into the build key. It defaults to Config.TemplateFS, so the usual arrangement needs no configuration; set it explicitly when your build reads more than the templates (an input.css you edit, a theme file), or to an empty FS to opt out.

Getting this wrong is unpleasant to debug, which is why it is not optional. Edit a template to add lg:grid-cols-6, and with the class set unchanged there is no rebuild — the stored stylesheet still holds sm:grid-cols-2 and not the lg: rule, and because it is linked after your site stylesheet it beats the lg: rule that build did emit. The element silently stays two columns at every width, and neither stylesheet looks wrong on its own.

Keep the two builds on one theme

This is a second, independent Tailwind build, and {{cmsHead}} links its output after the site stylesheet — so where the two disagree, this one wins. A v4 wrapper script whose input is a bare @import "tailwindcss" therefore re-emits @layer theme { :root { … } } with Tailwind's stock values, overwriting anything the site build customized: redefine --font-sans (or any other theme token) in the site's input.css and the generated stylesheet silently resets it site-wide. The same gap runs the other way for classes: a font-display an editor applies exists only in the database, so it is this build's job to compile — and a build that never saw --font-display emits nothing for it.

The fix is to keep theme tokens in a file both builds @import. cms init already generates that arrangement — assets/theme.css, wired into assets/input.css and tailwind-content.sh — so a scaffolded site only has to edit the tokens. For the same thing built by hand, see custom fonts in the quickstart, including why a wrapper script that stages its input in mktemp has to copy that file in rather than reference it.

Customizing the menu

Define your own entries with Config.EditorStyles; whatever you set replaces the defaults entirely:

c, err := cms.New(cms.Config{
    // ...
    EditorStyles: []cms.EditorStyle{
        // Inline styles wrap the selected text in a <span>.
        {Label: "Brand", Class: "text-brand-600"},
        {Label: "Subtle", Class: "text-slate-400"},
        {Label: "Highlight", Class: "bg-amber-100 px-1 rounded"}, // several classes are fine
        // A Block entry converts and styles the whole surrounding
        // block instead ("p", "h2", ...).
        {Label: "Lead paragraph", Class: "text-lg text-slate-600", Block: "p"},
        // Fonts are styles too: name the *role*, not the typeface. The
        // template must already load any webfont the class uses, and the
        // theme that defines --font-display has to reach both Tailwind
        // builds — see QUICKSTART.md#custom-fonts.
        {Label: "Display type", Class: "font-display"},
        // Entries sharing a Group fold into a submenu with that title,
        // placed where the group's first member appears. Handy once a
        // palette grows past a handful of entries.
        {Label: "Warning", Class: "text-amber-600", Group: "Callouts"},
        {Label: "Success", Class: "text-emerald-600", Group: "Callouts"},
    },
})

Rules of thumb:

  • Keep the list short and named for meaning ("Brand", "Warning", "Display type"), not appearance ("Dark red #8b0000") — that's what makes it safe to hand to non-technical editors.
  • Every class must exist in the site's CSS and be safelisted (when using Tailwind). Custom classes like text-brand-600 come from your Tailwind theme; bespoke-CSS sites can point entries at their own classes — the mechanism doesn't require Tailwind.
  • EditorStyles: []cms.EditorStyle{} (empty, non-nil) removes the custom entries; the dropdown itself stays, since it also carries the built-in Headings submenu.

Snippets

While editing, the tool rail (the dark strip on the left edge of the screen, visible only in edit mode) holds the creation tools: + Section, Snippets, and Page — the last opens a "Create a new page" dialog (page name + page type from your configured PageTemplates), creates the draft with a slug derived from the name, and takes the editor straight to it.

A PageTemplate marked Unlisted: true is left out of that dialog for everyone but superadmins (and the server refuses it on create for anyone else) — for one-off templates that only ever back a single page, like a home page or a staff directory, where offering them on every "new page" would invite a second. Pages already using an unlisted template render and edit exactly as before.

While editing, the edit bar also offers Delete for the current page (with a confirmation; the home page can't be deleted — the server refuses and the button doesn't appear on it).

The ⋯ menu's Page settings holds the two things a page carries that aren't on the page: its title — what fills <title> in your template and what search results show — and its meta description, which {{cmsHead}} emits as <meta name="description">. Both are per-language and both are staged like content, so they go live with the next Publish. On a translated page the fields start empty with the default language shown as a placeholder: type to translate, leave empty to keep following the original. Posts keep the same two fields in their ⚙ Post settings pill instead, next to the date and listing image.

The rail's Snippets button opens a drawer of ready-made blocks: drag one onto a rich region (or click to insert at the cursor), then edit its text and images in place like any other content. Snippets come from two places:

  • Config.Snippets — per-customer components, versioned with your code. Nil gets a Tailwind-first default library: inline blocks (callout, call-to-action, two columns, article text, quote, button link, video, flexible space, plus imported blocks — button pair, pill buttons, filled and outline single buttons in both shapes, quote with portrait, and four article layouts) and the section presets described under Sections below (hero, feature grid, stats, testimonials, FAQ, the three video layouts, call-to-action banner, plus imported presets — big headline, statement headline, kicker headline, team profiles, photo gallery, numbered features, pricing plans, achievements, client quotes, process steps, alternating steps, three product layouts, three skills layouts, two logo strips, two holding pages, and three map layouts — plus an inline map block); an empty slice ships none. The flexible space is invisible on the live site but shows as a striped, labelled band while editing — click it to set its height in pixels. The video block (and the video section presets) ship a "Click to add a video" slot: clicking it while editing offers the media library or a YouTube/Vimeo link, and the slot becomes a native <video> player or a privacy-enhanced embed. The imported photo-bearing blocks (gallery, products, team, quotes) ship the same affordance for images — a dashed "Click to add a photo" slot that opens the media library and becomes an <img> keeping the slot's shape (tile, wide, or circular portrait), cropped with object-cover. The map block and map sections ship a "Click to add a map" slot: paste a Google Maps link or its embed code, or just type an address, and the slot becomes a bounded maps <iframe> — no API key needed.
  • The admin UI (/admin/snippets, admins only) — for blocks and section presets created after deployment.

Clicking an inserted block raises its chrome:

drag the block anywhere on the page
move it up or down one place
▤ ▤ duplicate it above or below
⟨/⟩ edit the block's HTML
block settings — background and text colour, spacing, corner roundness
🗑 delete the block

The two move arrows trade the block with the one directly above or below it, and each hides when there is nothing on that side. They are the drag handle done precisely: a drag can go anywhere in the page but travels through the rich-text editor's drop caret, which will sometimes land a block inside the one it was aimed past; stepping over one sibling cannot.

The duplicate pair copies the block whole and lands the copy on the side chosen, with the chrome following the copy — it is the thing just made, so it is the thing about to be typed into. A duplicated custom-code block keeps its key, so the two placeholders are two instances of one library entry, which is what the block's own starter script is written to expect.

Everything on this toolbar is undoable with the usual keystroke.

Columns

A block that is a row of columns raises a second, smaller toolbar alongside the block chrome. It is drawn on the row's top border, centred over the column that was clicked — so it covers no content, and in a row of identical cells it is obvious which one it has hold of (that column is tinted too). Everything on it acts on that one column:

move this column along the row
⇥⇤ ⇤⇥ make it narrower or wider, a track at a time
▐▌ ▌▐ duplicate it to the left or the right
add a column after it
🗑 remove it

Duplicating keeps the column's contents; adding blanks them. Adding a column is making room for something not written yet, while duplicating one is wanting a second of what is already there — the third card in a row of cards, the fourth price tier.

A row also gets drag handles, one on each boundary between two columns, drawn in the gutter. Dragging one snaps to the twelve tracks as you go, so what you see mid-gesture is what you get — a column's width is a track span, and a free-form drag would only have to round itself on release and make the layout jump. The whole drag is one undo level, and a drag that ends where it began leaves the markup untouched, including the conversion to the twelve-track form it would otherwise have made.

The handles do not replace ⇥⇤ and ⇤⇥. Those two are the keyboard and touch path, and they still say in words what the handle says by being where it is. What the handle adds is that it names its own pair: the buttons have to pick a neighbour by rule (the next column, or the previous one for the last in the row), while a boundary you grab is unambiguous.

Handles appear only where those buttons do — a row whose track count does not divide twelve has neither. They also hide when the row is drawn stacked, which it is on a phone: there is no gutter to put one in, and a drag there would be editing the sm: width while showing the mobile layout.

A column here is a real box with its own content, never a slice of one continuous stream. That distinction is the whole design. CSS offers the other thing — column-count, which reflows text down one column and up into the next — and it is almost never what someone means by "put this in two columns"; it also puts the leading paragraph's top margin at the top of the first column and not the second, so the two start at visibly different heights.

A block that is not a row yet gets the same toolbar with the two edits that can make it one:

  • , captioned Split into two columns — what was there goes in the first column, a placeholder in the second. Blocks built around a placed thing rather than a written one (a button, a video or photo slot, an image, a nested block) are not offered this, and neither is an empty block: cutting a block designed around a picture in half is a judgement about its design, not a column edit.

  • ▐▌ ▌▐, Duplicate this block to the left / right — the block is wrapped in a fresh two-column row with a copy of itself beside it. Copying a block whole is not that judgement, so the button and photo blocks the split refuses are welcome here; all it needs is something in the block to copy. This is the only way left and right mean anything for a block, since blocks are a stack and only columns sit side by side.

    The block goes into its column intact — background, padding and rounding travelling with it — so a duplicated callout is two tinted boxes side by side rather than one wide tint with the words twice inside it. Custom-code blocks stay out: the wrapping row takes over as the block, and a code block that is no longer the block can no longer open its library entry from ⟨/⟩. Duplicating one above or below from the block chrome keeps it whole.

Once a block is a row, the full toolbar applies to it whatever it holds.

A new column is a copy of the one you added it after, so it keeps that row's classes and any photo or video slot it carried, with its words replaced by placeholders. Removing a column that holds real content asks first. Taking the last column out of a row removes the row, and where the row is the block, the block goes with it.

Two forms of row markup exist, and the tool moves between them exactly:

  • the even form, sm:grid-cols-K with K cells carrying no span of their own — what every stock snippet ships, and what a row goes back to whenever its columns are equal and there are four or fewer;
  • the spanned form, sm:grid-cols-12 with a sm:col-span-N on each cell summing to twelve — taken on the moment a column is resized, or when a row grows past four columns.

Twelve divides by every track count the even form uses, so sm:grid-cols-3 becomes twelve tracks of span 4 with nothing rounded and nothing moved. A row whose track count does not divide twelve (five tracks, seven) can still gain and lose columns; it just can't be resized, and says so by hiding those two buttons rather than by rounding. Resizing always steps a pair of neighbours — one track out of the column next door, or back into it — so the row stays exactly full and no sequence of clicks can leave a gap or an overflow. Six columns is the ceiling.

Adding or removing a column re-evens the widths. That is deliberate: after a fourth column joins a row someone had set to 8-4, no redistribution of the old widths is the obvious one, and even columns are both predictable and one click from being reshaped again.

Two details worth knowing. Counts are read from the largest grid-cols-* class on the element, so the common grid-cols-1 sm:grid-cols-2 idiom edits the sm: rule and leaves the mobile stack alone — and every rewrite goes back at the prefix it was read from. And a block holding several separate grids gets no tool rather than a guess about which one was meant.

Every class the tool can write is declared in Go, by render.EditorAppliedClasses — these are classes the editor puts into content on its own, which no snippet carries and no scan of stored content can find until after the first save that uses one. They are folded into the generated content stylesheet's corpus so a fresh install is covered, and the safelists below are checked against them by a test, so the two cannot drift. Those are the sm: forms, because that is what every stock snippet uses. If your own snippets set their tracks at another breakpoint — or at none — the tool writes back at whatever prefix it read, and you have to safelist that prefix's grid-cols-* and col-span-* yourself.

Snippets: []cms.Snippet{
    {Name: "Pricing card", HTML: `<div class="not-prose rounded-xl border p-6">
        <p class="text-3xl font-bold">$99</p>
        <p class="text-slate-600">per month</p></div>`},
},

Guidelines: wrap components in not-prose so Tailwind Typography doesn't restyle their internals; avoid <script> and SVG (stripped when editor-role users save); and safelist every class that appears only in snippets — the same database-content rule as the Styles menu.

Set Group on a snippet to file it under a category: whenever the loaded snippets carry two or more categories, the drawer grows a dropdown that filters the list (per mode — a category whose entries are all section presets isn't offered while inserting inline). Grouping is config-only, like EditorStyles groups; admin-created and ungrouped snippets pool under Custom. The default library is grouped as Basic, Buttons, Quotes, Media, Article, Headlines, Features, Stats, Team, Pricing, Products, Process, Skills, Partners, and Coming soon. The magnifier in the drawer header opens a search field that filters the list by name, combining with the category dropdown.

For the default library add:

safelist: [
    "not-prose", "rounded-lg", "rounded-xl", "border", "border-blue-200",
    "border-slate-200", "bg-blue-50", "bg-blue-600", "bg-slate-50",
    "bg-slate-900", "bg-white", "p-4", "p-6", "px-5", "px-6", "py-2.5",
    "py-3", "text-blue-700", "text-blue-900", "text-white",
    "text-slate-500", "text-slate-600", "text-slate-700", "text-center",
    "text-sm", "text-lg", "text-xl", "text-2xl", "text-3xl", "text-4xl",
    "sm:text-5xl", "tracking-tight", "font-semibold", "font-bold",
    "mb-1", "mb-2", "mb-3", "mt-1", "mt-3", "my-4", "my-6", "my-8",
    "grid", "gap-6", "gap-8", "sm:grid-cols-1", "sm:grid-cols-2",
    "sm:grid-cols-3", "sm:grid-cols-12",
    "columns-1", "columns-2", "columns-3",
    "inline-block", "flex", "items-center", "justify-center",
    "w-full", "aspect-video", "border-2", "border-dashed",
    "border-slate-300",
    // Used by the imported block library (buttons, profiles, gallery,
    // numbered features, pricing, achievements, client quotes):
    "aspect-square", "bg-slate-200", "border-blue-600",
    "border-slate-900", "h-0.5", "hover:bg-blue-600",
    "hover:bg-slate-300", "hover:bg-slate-900", "hover:text-white",
    "mr-2", "mt-2", "mt-4", "mt-6", "mt-10", "px-8", "size-24",
    "sm:col-span-1", "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:col-span-10", "sm:col-span-11", "sm:col-span-12",
    "sm:grid-cols-4", "text-5xl", "text-6xl",
    "sm:text-7xl", "text-slate-200", "text-slate-400", "text-slate-900",
    "tracking-widest", "uppercase", "w-10", "object-contain",
],

Deleting a snippet never changes pages that already inserted it — inserted snippets are ordinary page content.

Custom code blocks

Some blocks are not content: an availability calendar, a pricing calculator, a third-party embed that needs a line of setup. These are custom code blocks — markup with its own <script> — and they are admin-only.

They are not stored in the page. What a page holds is an inert placeholder naming a library entry:

<div class="cms-snippet cms-code" data-cms-code="booking-widget"></div>

The code itself lives in a library behind the admin-only /api/code endpoints, and a public render swaps each placeholder for the markup its key names, keeping the placeholder's own <div> as the wrapper. A block finds itself with document.currentScript.closest(".cms-code"), so the same block used twice on one page still scopes to its own markup.

The split is what makes the feature safe and durable at once. Region and section HTML is sanitized on every non-admin save: executable markup left inline would either be stripped — silently deleting the widget the first time an editor fixed a typo in the same section — or have to be safelisted, which would hand every editor a script-injection hole. A placeholder carries nothing executable, so it rides through the sanitizer untouched.

To use one, an admin opens the Snippets drawer and clicks Custom code, then picks a library entry or creates one. On the page the block shows as a labelled card while editing; click it and press the ⟨/⟩ button in the block chrome to edit its code, which saves immediately and applies everywhere the key is used. The usual chrome moves and deletes the block like any other. Nothing executes while the page is being edited — an edit render leaves the placeholders alone — so the code runs on the public page and in the admin's preview, and nowhere else.

Two things worth knowing:

  • Deleting a library entry leaves placeholders that name it rendering as nothing; recreating the key brings them back.
  • For code that belongs to a page rather than to a spot in it, the wrench menu's Page CSS & JS and Site CSS & JS panels are still the right tool — they inject into <head> and before </body>.

Sections

Snippets live inside a region's column; sections let editors compose the page itself. Place {{cmsSections "body"}} in a template outside any max-width container (it renders full-bleed), and editors can add, reorder, restyle, and delete full-width sections directly on the page: each section has a control pill (↑ ↓ + ⚙ ✕) in edit mode, new sections start from a snippet or empty, and the ⚙ settings offer curated choices only — background (Default / Light gray / Dark / Accent), content width (Normal / Wide / Full width), and rounded corners (None / Small / Medium / Large) by default — alongside a free-form background colour and background image. The dialog splits into Layout and Background tabs, with a live preview under both. A background image is cropped to cover the section, so it comes with two sliders — across and down — that place which part of it survives the crop, the difference between a portrait's face and its shoulder. The + buttons — on each section, at the bottom of the sections area, and on the tool rail — open an "Add a section" chooser: start empty, seed the section from any snippet, or pick a section preset (below).

Section presets

A config snippet that carries Settings is a section preset: a one-click starting point for a whole section, not an inline block. The editor lists presets first in the "Add a section" chooser (tagged "Section") and hides them from the inline-insert drawer; choosing one creates the section with the settings already applied along with the starting HTML. The default library ships thirty-three — Hero (dark, 75% screen height, centered), Feature grid, Stats, Testimonials, FAQ, Full-width video, Text + video, Video + text, and Call-to-action banner; twenty-one converted from a commercial block library: Big headline, Statement headline, Kicker headline, Team profiles, Photo gallery, Numbered features, Pricing plans, Achievements, Client quotes, Process steps, Alternating steps, Product pair, Product cards, Services list, Skill percentages, Skill circles, Skill rings, Partner logos, Featured on, Coming soon, and Maintenance mode; and three map sections (Full-width map, Text + map, Map + text) built on the map slot — so a blank-canvas page can be composed into a landing page without touching the settings dialog.

Settings use the section-settings vocabulary: bg, width, and corners name SectionStyles option keys, height is "50"/"75"/"100" (percent of the screen), valign is "center"/"bottom", and the free-form bgcolor (#rrggbb), bgimage (URL) and bgposition — a pair of percentages across and down, e.g. "50% 20%", centered by default — work too. Unknown keys and invalid values fall back to the defaults. Register your own next to ordinary snippets:

Snippets: append(
    append(snippets.DefaultSnippets(), snippets.DefaultSectionPresets()...),
    cms.Snippet{
        Name:     "Brand hero",
        Settings: map[string]string{"bg": "brand", "height": "100", "valign": "center"},
        HTML:     `<div class="cms-snippet text-center"><h1>Big claim</h1></div>`,
    },
),

Admins can create presets after deployment too: the snippet form at /admin/snippets has a "Section preset" type with the curated settings (background, width, rounded corners, height, vertical alignment); the free-form bgcolor, bgimage, and bgposition settings are config-only. Everything after creation is ordinary: the preset's settings land in the section's ⚙ dialog, its HTML is normal editable content, and neither remembers where it came from.

Each section renders as:

<section class="BG-CLASSES CORNER-CLASSES">
  <div class="WIDTH-CLASSES">…editor content…</div>
</section>

The sections-only page pattern. For pages editors should compose entirely themselves, offer a page type that is nothing but chrome plus a sections area — no fixed regions at all:

{{template "base" .}}

{{define "content"}}
{{cmsSections "sections"}}
{{end}}

Add it to PageTemplates (the example ships one as "Blank canvas") and the New-page dialog offers it alongside your structured layouts. This is deliberately a page type, not a per-page "hide region" switch: the CMS can only empty a region's hole, never remove the wrapper markup your template put around it, so suppressing fixed regions is the template's decision. Pages can switch between types later in the admin — content in regions both templates share (like the sections area) carries over.

Customize the choices with Config.SectionStyles (nil gets the Tailwind-first defaults). Only the option keys are stored with content; classes resolve at render time, so changing an option's classes restyles every existing section using it:

SectionStyles: &cms.SectionStyles{
    Backgrounds: []cms.SectionOption{
        {Key: "default", Label: "Default", Class: ""},
        {Key: "brand", Label: "Brand", Class: "bg-brand-900", ContentClass: "prose-invert"},
    },
    Widths: []cms.SectionOption{
        {Key: "normal", Label: "Normal", Class: "prose mx-auto max-w-3xl px-6 py-12"},
        {Key: "full", Label: "Full width", Class: "prose max-w-none px-6 py-12"},
    },
    Corners: []cms.SectionOption{
        {Key: "none", Label: "None (square)", Class: ""},
        {Key: "soft", Label: "Soft", Class: "rounded-2xl"},
    },
},

Corners rounds the <section> wrapper itself (where the background paints), so a tinted band reads as a card. Leaving Corners nil keeps the default choices (None / Small / Medium / Large → rounded-lg / rounded-2xl / rounded-3xl); an explicit empty slice ([]cms.SectionOption{}) removes the setting from the ⚙ dialog entirely. The first option is the default and should normally be a no-class "none".

Paddings is the vertical breathing room around a section's content, as its own axis. It is configured by default — Normal / Roomy / Snug / Tight / None → py-12 / py-20 / py-6 / py-3 / py-0 — and the default Widths carry no py-* of their own, so the two compose:

Paddings: []cms.SectionOption{
    {Key: "normal", Label: "Normal", Class: "py-12"},
    {Key: "tight",  Label: "Tight",  Class: "py-3"},
    {Key: "none",   Label: "None",   Class: "py-0"},
},

Separating it from Widths is worth it for two reasons:

  • Bundled into Widths, "the same measure but tighter" has no expression except a second width option, and the list multiplies by every spacing anyone wants.
  • It is not the same thing as the section height setting, which is a min-height and can only ever make a section taller. An editor who wants less space reaches for height first, finds "Auto" already selected, and concludes the CMS cannot do it.

The class is emitted after the width class, so moving a py-* out of a width preset and into Paddings needs no thought about ordering. The first option is the default and is what content saved before the axis existed resolves to — make it match whatever padding your width presets used to carry and nothing already published will move.

Replacing SectionStyles wholesale replaces this too: a host that keeps its py-* inside the width presets simply leaves Paddings nil and the axis does not appear. It is not backfilled the way Corners is, because a default py-12 appended to width classes that already carry their own spacing would fight it.

Safelist the default section classes along with the rest:

safelist: [
    "bg-slate-50", "bg-slate-900", "bg-blue-700", "prose", "prose-slate",
    "prose-invert", "mx-auto", "max-w-3xl", "max-w-5xl", "max-w-none",
    "px-6", "py-12", "py-20", "py-6", "py-3", "py-0",
    "rounded-lg", "rounded-2xl", "rounded-3xl",
],

Blog & news

Blog and news are two feeds of the same engine. Enable them by giving the CMS a post template:

PostTemplate: cms.PageTemplate{File: "templates/pages/post.gohtml", Label: "Post"},

A post is an ordinary page underneath: its slug lives under blog/ or news/ (e.g. /blog/launch-day), its body is regular region/section content, and draft/publish works exactly like pages. That means posts are edited the same way pages are — open the post on the site, press Edit, and write in place with sections, snippets, and the media picker. The post template never appears in the page-template choosers; posts are created from the editor tool rail's Post button or under Blog & News in the admin. The rail dialog takes everything up front: title, feed (Blog or News), summary, date/time, and an image chosen through the media picker (browse the library or upload) — that one image does two jobs, becoming the background of the post's banner section and its card in the listings. You land in the new draft with the banner already on it, ready to write. While editing a post in place, a ⚙ Post settings pill pinned to the top-right of the page reopens those settings, and holds the rest of them: title, summary, meta description, date, byline, and listing image — the same fields as the admin's post form. The date, the byline, and the image go live the moment they are saved, like menus, since they order, sign, and illustrate the post rather than being page content; the title, summary, and meta description are page metadata and reach the site with the next Publish. On a translated site the pill edits the language the page is rendered in, so a French post gets a French title and summary. The creating user is stamped as the author; feed and address changes live on the admin form.

Two of those settings are particular to posts:

  • Meta description. A page has one description, which is its meta description. A post's description is its summary — the blurb on listing cards and in RSS — which is not always the line worth showing under a search result, so a post carries a second, per-locale meta description beside it. Empty means "use the summary", which is what every post that has never set one says, so nothing changes until it is filled in. {{cmsHead}} publishes whichever applies.
  • Show the author's name. Off publishes the post under the site's name: the date still prints, the byline does not. The author is not changed — it stays recorded and comes back when the setting does — and templates need nothing new, because a post with its byline off reports an empty .Author, which the {{with .Author}} a byline is already written with skips.

A post has no header field. The banner at the top of a post is a section in a region of its own, so it is edited on the page with the same gear as every other section — background image and where to anchor it, content width, rounded corners, height, vertical alignment, and any text laid over it — and it follows the draft/publish flow rather than going live the moment it is chosen. Creating a post with an image seeds that section with it; creating one without leaves the region empty, and its own "Add section" button is how a banner gets added later. Give the post template a sections region above the article and there is nothing else to wire up:

{{cmsSections "header"}}
<article>
  {{if not (cmsHasSections "header")}}<h1>{{cmsTitle}}</h1>{{end}}
  {{with .Post}}<p>{{cmsDate .PublishedAt}}{{with .Author}} · {{.}}{{end}}</p>{{end}}
  {{cmsSections "sections"}}
</article>

A seeded banner starts with the post's title as the page's <h1>, centered over the picture and in white or near-black depending on how dark the image measures. {{cmsHasSections "header"}} reports whether an area actually holds anything — which cmsSections cannot be used for, since an edit render always emits its wrapper — so the template can leave the title to the banner when there is one and print its own <h1> when there is not. Showing it in both places is the thing to avoid — the same words, twice, on one page. Either heading is edited where it sits: the banner's is a line of text inside a section, and the no-banner one is {{cmsTitle}}.

Order tells the CMS what the regions are for: the last sections region a template declares is its main content, where a new page's starter section goes, and the first — when there is more than one — is the banner, where a new post's chosen image goes. While editing, each "Add section" button is tagged with its region's name so the two areas are told apart.

The post template is a page template that additionally receives the post's metadata as .Post (nil on ordinary pages).

Any template can list posts with cmsPosts — feed name and a limit (≤ 0 for all), newest first. It does not paginate; for a listing that walks the whole feed see Paginated listings below:

{{range cmsPosts "blog" 12}}
  <a href="{{.URL}}">
    {{with .Thumbnail}}
      <img src="{{.URL}}" srcset="{{.Srcset}}" sizes="(min-width: 640px) 21rem, 100vw"
           width="{{.Width}}" height="{{.Height}}" alt="{{.Alt}}" loading="lazy">
    {{end}}
    <h2>{{.Title}}</h2>
    <p>{{cmsDate .PublishedAt}}</p>
    <p>{{.Summary}}</p>
  </a>
{{end}}

Each entry carries Feed, Title, Summary (the page description), URL, PublishedAt, Author, Thumbnail, and Draft. Author is empty on a post whose byline is switched off in its settings, so {{with .Author}} · {{.}}{{end}} is all a template needs to honour that — the date stays either way.

Print dates with {{cmsDate .PublishedAt}} rather than Go's own .Format: it writes the date in the language the page is being rendered in ("July 30, 2026", "30 juillet 2026"), where .Format names months in English on every page whatever language it is written in. Add "short"{{cmsDate .PublishedAt "short"}} — for the abbreviated month in a tight listing. English and French are built in; any other locale formats as English, and a template is free to format its own dates instead.

Paginated listings

cmsPosts shows the newest N posts and stops; older posts are reachable only by their own URL. For a listing that walks the whole feed, use cmsFeed instead. It returns one page of posts plus everything needed to link to the others, and reads the page number from ?page= on the request — the query runs LIMIT/OFFSET server-side, so a long feed never loads more than a page's worth of rows:

{{$feed := cmsFeed "blog"}}
{{range $feed.Posts}}
  <a href="{{.URL}}"><h2>{{.Title}}</h2></a>
{{end}}
{{cmsPagination $feed}}

Page size comes from Config.PostsPerPage (CMS_POSTS_PER_PAGE, default 10). Pass a number to override it for one listing: {{cmsFeed "blog" 6}}.

{{cmsPagination $feed}} emits a ready-made bar — Previous, numbered page links, Next — under cms-pager* classes your stylesheet can restyle, the same arrangement as cmsNav. To own the markup instead, build it from the FeedPage fields:

Field What it holds
.Posts this page's posts, the same PostInfo entries cmsPosts returns
.Page, .TotalPages where this page sits; TotalPages is at least 1
.Total, .PerPage posts in the whole feed, and per page
.PrevURL, .NextURL adjacent pages; empty at either end
.Links the numbered bar: .Number, .URL, .Current, .Ellipsis
.HasPages true when the feed runs to more than one page

.Links shows every page in a short feed; in a long one it shows the first page, the last, the two either side of the current one, and an .Ellipsis entry for each gap. examples/basic has both versions — blog.gohtml calls cmsPagination, news.gohtml draws its own.

A ?page= past the end of the feed lands on the last real page rather than an empty listing, and one that is not a number is page 1; neither is a 404. Page 1's links omit page= altogether, so the canonical listing URL stays the bare one, and any other query parameters on the URL are carried across pages.

Thumbnail is the post's listing image with every rendition resolved, and is nil when the post has none. It has URL (a default src at card size), Srcset (every rendition, so the browser can pick a better one), Width and Height (the intrinsic size of URL, which stops the page reflowing as images arrive), and Alt from the media library. Write sizes yourself: only your template knows the layout.

ThumbnailURL is still there for templates that just want one string, and holds the same address as .Thumbnail.URL.

The public sees published posts only; logged-in editors also see drafts (Draft is true, so listing templates can badge them — see examples/basic/templates/pages/blog.gohtml). Listing pages themselves are ordinary pages: create a page at slug blog or news using a listing template.

RSS is served automatically at /blog/rss.xml and /news/rss.xml — the twenty newest published posts, with the channel title and description taken from the published listing page at /blog or /news when one exists.

French & multilingual content

Configure the site's languages and everything else follows:

Locales: []string{"en", "fr"}, // first entry is the default

With one locale none of this surfaces. With more, the default language lives at /about and the others under their code — /fr/about, /fr for the French homepage, /fr/news/rss.xml for a localized feed. Put {{cmsLocales}} in a template for a language switcher (each entry has .Code, .URL, .Active — see the example site's header), and {{cmsHead}} emits hreflang alternates automatically.

Translating is in-place editing. The edit bar shows an EN | FR switcher; flip to FR and the page renders with English fallback wherever no French exists yet — those regions get a dashed amber outline in edit mode. Edit them (the English text is your starting point), save, and the French version now exists; anything you don't touch keeps following the English original, region by region. Publish applies to all languages at once. A page's title and meta description translate the same way, in the ⋯ menu's "Page settings" (a post's, in its ⚙ pill): the fields start empty with the English shown as a placeholder, and an empty field keeps following the English. The ⋯ menu's "Remove this translation" reverts the current language back to fallback. Menu labels are translated the same way: right-click a nav item while on the French site and the label you type is the French override.

In the admin, the page and post forms grow EN/FR tabs — title, summary, and region source are per-language; address, template, feed, date, and images live on the default tab. The admin UI itself speaks English and (Canadian) French: a toggle by the logout button switches per user.

Navigation menus

Drop {{cmsNav "main"}} into a template and the CMS renders the whole nav — including one level of dropdown submenus, with the toggle behavior built in. Use any menu key you like ("main", "footer", …). The markup carries stable classes your stylesheet targets:

<nav class="cms-nav">
  <button class="cms-nav-burger">…</button> <!-- hamburger; hidden above 768px -->
  <ul class="cms-nav-list">                <!-- horizontal flex list -->
    <li class="cms-nav-item">
      <a class="cms-nav-link cms-active" aria-current="page" href="/">Home</a>
    </li>
    <li class="cms-nav-item cms-nav-drop"> <!-- dropdown parent; .cms-open while open -->
      <button class="cms-nav-link cms-nav-toggle">Services<span class="cms-nav-caret"></span></button>
      <ul class="cms-nav-sub">…</ul>       <!-- the dropdown panel -->
    </li>
  </ul>
</nav>

On screens 768px and narrower the list collapses behind the hamburger button: tapping it sets .cms-nav-open on the nav and the items open in a left-aligned panel anchored below the button — the header keeps its height — with dropdown submenus expanding inline inside the panel. Override the .cms-nav-burger / @media rules in your CSS to change the breakpoint or the look.

The CMS injects only the functional minimum (layout of the list, hiding and positioning of dropdown panels, a neutral panel look, the mobile collapse) — colors, spacing, and typography come from your CSS, and every injected rule can be overridden by class.

A dropdown's parent is a <button> rather than an <a>, so it can be opened from the keyboard, but it carries .cms-nav-link exactly like a plain item — style that one class and both look the same. The reset that undoes the browser's button styling is wrapped in :where(), so it has zero specificity and your rule wins even though it names an element.

Editing happens right on the nav. While in edit mode, editors right-click any menu item (long-press on touch) to set its text, link it to a page (searchable picker; the URL follows slug renames, and the item disappears if the page is deleted) or a web address, open it in a new tab, or turn a top-level item into a dropdown — a label-only item that holds other items, one level deep.

A "web address" is a site-relative path (/contact), an https://, http://, mailto: or tel: URL, or a same-page anchor (#pricing) — a one-page site's nav is made of anchors. A bare # is refused, since it links nowhere. Note that #pricing only resolves on a page that has that anchor, so on a site with more than one page /#pricing is usually what is meant: the nav renders on every page, and the bare form does nothing on the others. "+" chips add items, and dragging rearranges them, including into and out of a dropdown. Items linking to draft pages show only for logged-in editors until the page is published, and items linking to private pages (visibility "Private" in the page form) stay editor-only even after publishing. Menu changes have no draft state — every change applies to the whole site immediately.

Prefer to own the markup completely? cmsMenu returns the raw entries — Label, URL, Active, NewTab, External, and Children for dropdown parents (whose own URL is empty):

<nav class="flex gap-6">
    {{range cmsMenu "main"}}
    <a href="{{.URL}}"{{if .NewTab}} target="_blank" rel="noopener"{{end}}
       class="hover:text-slate-900{{if .Active}} font-semibold{{end}}">{{.Label}}</a>
    {{end}}
</nav>

Hand-rolled navs render the same data but aren't right-click editable — the in-place menu editor only attaches to cmsNav markup.

A footer is the same on every page, so nobody should have to build one page by page. {{cmsShared "key"}} is a rich region like cmsRegion, except that its content is stored once for the site and rendered on every page that uses the template:

<footer class="border-t mt-16">
  <div class="mx-auto max-w-4xl px-6 py-8 text-sm text-slate-500">
    {{cmsShared "footer" "<p>&copy; Example Site</p>"}}
  </div>
</footer>

Put it in your layout and every page has an editable footer from the first request — no page to create, nothing to seed. The optional second argument is markup to show while the region is empty, so a fresh site says something sensible; it disappears the moment an editor saves content, and comes back if they empty the region again.

Editing works exactly as it does for page content: an editor clicks into the footer on whichever page they are on, TinyMCE opens inline, snippets and images drop in, and Save draft stages the change. What differs is scope, and it is worth being clear with editors about it:

  • Nothing goes live until a Publish, but any page's Publish makes it live. Shared content has no page of its own to be published from, so it rides along with whichever page the editor publishes — the same page they were editing it on. The status chip counts unpublished shared edits, so a page showing "Unpublished edits" may be reporting a footer change rather than one of its own.
  • Discard is page-only. Discarding a page's draft leaves shared edits alone rather than silently throwing away work done elsewhere on the site. To undo a footer change before publishing, edit it back.
  • Translations work per region as everywhere else: a shared region with no content in the current locale renders the default language with the usual dashed amber "not translated" badge, and editing it writes that locale's copy.

Templates may declare as many shared regions as they like ({{cmsShared "footer"}}, {{cmsShared "contact-strip"}}), and a shared region and a page region may safely have the same name — they are different regions. Shared regions are rich HTML only; there is no shared equivalent of cmsText, cmsImage, or cmsSections.

Under the hood the content lives in cms_blocks like everything else, against one reserved system page (slug __site) that never appears in the admin's Pages list and is not reachable as a URL. That is what lets shared content reuse drafts, publishing, sanitization, and locales unchanged.

The notice bar

A holiday closure, a delivery delay, a service interruption: the one thing the whole site has to say at once, above everything else, on every page. Site settings → "Show a notice bar at the top of every page" switches one on.

It needs no template change. A layout that never mentions the bar gets it injected immediately after its <body> tag, which puts it above the header — and therefore above the menu — on every page of a site that predates the feature:

<body>
  <div class="cms-notice cms-notice-warning">…</div>   <!-- the CMS puts this here -->
  <header>…{{cmsNav "main"}}…</header>

To place it yourself instead — under a fixed header rather than above it, or inside a wrapper your CSS grid owns — call {{cmsNotice}} and the bar renders there and nowhere else.

The words are not a setting. They are a shared region — content, with everything that implies: per-language, sanitized, staged as a draft and live on the next Publish. That is deliberate; a settings value has no locale, and a notice that could only be written once would be wrong on half a bilingual site. {{cmsShared "notice"}} reaches the same content if you ever want it somewhere else as well.

There are two ways to write them, and they store the same thing:

  • In the dialog. "What it says" is an editable box right under the switch, with bold, italic and links — everything a notice bar has ever wanted — so a closure notice is one visit: tick, write, Save. The wording saves as a draft (the switch itself is live at once, like every other setting) and goes live with the next Publish, in the language being edited.
  • In the bar. Click into it on the page and type, exactly as you edit the footer, with the site's full editor behind it.

The two do not fight: the dialog's box shows whatever the bar currently holds, formatting and all, including typing you have not saved yet — and a save that leaves the wording untouched writes nothing at all, so visiting the dialog to change a colour cannot disturb the notice.

The dialog's box keeps bold, italic, links and line breaks, and nothing else; ⌘B and ⌘I work, pasted text arrives as words, and a link is checked before it is accepted (javascript: and friends are refused). Anything richer than that — a picture in the bar, say — is written in the bar itself.

Three switches go with it:

  • Colour — one of five curated schemes (dark, accent, warning, alert, light). The CSS ships with the CMS rather than coming from your Tailwind build, so the bar looks right on a site whose stylesheet has never heard of it, and nothing here needs safelisting.
  • "Let visitors close the notice" — adds a close button. The dismissal is remembered in the visitor's browser against the notice's current wording, so rewriting the notice shows it again to everyone who closed the last one. Without it the bar stays until it is switched off. Logged in, the button closes the bar for that pageview only and nothing is remembered — so you can see what visitors see without losing the bar you may need to edit. While you are actually editing the page it does nothing at all, since the bar is a region you might be typing in.
  • The switch itself — off is off everywhere, whatever is written in the bar.

A bar switched on with nothing written in it shows to nobody; an editor sees it with a placeholder to type over. Emptying the notice therefore hides the bar as surely as switching it off does — and the placeholder itself is never stored, so it cannot reach the live site.

Making it scroll away under a fixed menu

The bar sits in the normal flow at the top of the page, so if your header is sticky you get the behaviour for free — the notice scrolls out of sight and the menu stays pinned. This is all it takes, in your own stylesheet:

header { position: sticky; top: 0; }

A position: fixed header is the case that needs work, because it is out of the flow and will sit on top of the bar; sticky is the simpler answer. Style the bar itself by overriding .cms-notice and friends — .cms-notice-inner is the centred container, .cms-notice-text wraps the editable words, .cms-notice-close is the close button.

Site settings: brand, favicon & menu alignment

The wrench menu's Site settings entry lets editors set the site name, an optional logo, a favicon, and the nav's alignment without touching templates. Put {{cmsBrand "Fallback Name"}} where your header shows the brand (typically inside your logo link):

<a href="/">{{cmsBrand "Example Site"}}</a>

It renders a span.cms-brand holding the stored logo (img.cms-brand-logo, sized to 1.6em unless your CSS says otherwise) and/or the stored site name (span.cms-brand-text). Save a logo and clear the name for a logo-only brand; the fallback argument shows until either is set. Saves are live immediately — like menus, there is no draft state.

Where markup does not fit — the <title>, an og:site_name meta tag, a copyright line — {{cmsSiteName "Fallback Name"}} gives the same stored name as plain text, escaped for wherever it lands:

<title>{{.Title}} — {{cmsSiteName "Example Site"}}</title>

Sites scaffolded before this function existed have the name written into base.gohtml as a literal; swap it for the call above and the title follows the dialog from then on.

The favicon is picked from the media library — PNG, JPEG, GIF, WebP, or SVG, whichever you uploaded, unmodified rather than re-encoded — and {{cmsHead}} emits it as <link rel="icon"> on every page. It is the one image field that uses the original file instead of the library's downscaled web rendition, since a browser tab paints it at 16px and a lossy re-encode buys nothing there. Leave it unset and the CMS emits nothing at all, so a <link rel="icon"> in your own base.gohtml (or the browser's /favicon.ico guess) keeps working.

Menu alignment (left / center / right) adds a cms-nav-left / cms-nav-center / cms-nav-right class to cmsNav markup, which makes the nav grow (flex:1) inside your header's flexbox and justifies the items within it. "Theme default" adds no class and leaves your layout alone.

The same dialog carries a Site mode switch, which only superadmins see. A site in development is built and browsable but asks search engines to leave it alone; switching it to production is what makes it findable. SeedAdmin starts a brand-new site in development, so a site is never indexed while it is still being written. Sites that predate the setting stay in production — an upgrade never quietly pulls a live site out of search.

In development the CMS:

  • sends X-Robots-Tag: noindex, nofollow on every public response — pages, RSS feeds, and media. The header is what covers the files no <meta> tag can reach: search engines index a PDF or an image on its own, and the media proxy serves both.
  • emits <meta name="robots" content="noindex, nofollow"> from {{cmsHead}}.
  • serves /robots.txt with Disallow: /.

In production it does none of those things, and — unless you have written a robots.txt in the site settings (below) — does not claim /robots.txt at all. If your app serves its own (with a Sitemap: line, or rules for a particular crawler), it keeps serving it the moment the site goes live; the CMS's copy is only there while the site is hidden.

Two things worth knowing:

This hides the site; it does not protect it. Everything is still served to anyone with the address, and a crawler that ignores the rules is not stopped by them. If an unfinished site must not be reachable, that is HTTP auth, an IP allowlist, or not pointing a public name at it — none of which the CMS does for you.

Development mode is for a site that was never indexed. Disallow keeps crawlers out, and a crawler that never fetches a page never sees the noindex on it. That is the right trade before launch, when there is nothing in anyone's index yet. It is the wrong tool for pulling a site that has been live back out of search results: for that the pages have to stay crawlable so the noindex can be read, which means removing the URLs through the search engine's own tools rather than flipping this switch.

Everywhere else the mode stays visible: while a site is in development the admin sidebar carries a Development stamp under the brand, on every page, because the failure this feature invites is a finished site nobody remembered to switch over.

A robots.txt for the live site

Under the mode switch — and, like it, superadmin-only — the site settings dialog carries a robots.txt box. Whatever you write there is served verbatim at /robots.txt once the site is in production:

User-agent: *
Disallow: /private

Sitemap: https://example.com/sitemap.xml

On a site that has never stored one, the box opens on a working starting point rather than empty — crawl everything except the admin, at whatever Config.AdminPath you mounted it on:

User-agent: *
Disallow: /admin/

Nothing is stored until you save, and the note under the box says so: the save is what takes /robots.txt over from the host app. Clear the box and save to hand it back.

Three rules govern it, and they are worth stating plainly:

  • Empty means the CMS serves nothing there. That is the default and the behaviour every existing site keeps: the path stays the host app's, and an app already serving its own file is unaffected by this feature existing. The suggested text above is only ever a suggestion — it takes a save to become real.
  • Development ignores it. A hidden site serves its own Disallow: / no matter what is stored, because a file written for the live site would otherwise invite crawlers into an unfinished one. The box says so while the site is in development.
  • Only superadmins may edit it. Admins and editors see the dialog and save the rest of it normally; their save carries the stored file through untouched, the same way it carries the mode.

Responses are sent Cache-Control: no-store, so an edit is live at once as far as any proxy is concerned. Crawlers cache robots.txt on their own schedule regardless — Google for about a day — so a change takes effect on their next fetch, not yours.

This is a text box, not a validator: the CMS caps the length and normalizes line endings, and otherwise serves what you typed. A Disallow that hides a page from search does not make it unreachable — that is the same caveat as development mode, and worth re-reading above.

A sitemap

Above the robots.txt box, and superadmin-only in the same way, is Publish a sitemap at /sitemap.xml. With it on, the CMS generates a sitemap of every page it serves:

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://example.com/</loc>
    <lastmod>2026-08-15T12:04:11Z</lastmod>
  </url>
  <url>
    <loc>https://example.com/about</loc>
    <lastmod>2026-07-02T09:30:00Z</lastmod>
  </url>
</urlset>

What it lists is every page that is published and publicly visible — posts included, since a post is a page. Drafts and private pages are left out, which means the sitemap says exactly what an anonymous visitor could reach anyway.

lastmod is the live page's date, not the editor's. It moves when a page is published, unpublished, renamed, or has its visibility changed, and stays put while someone works on a draft — draft edits are stored separately from the page row. So editing all afternoon without publishing does not tell search engines the page changed, which is correct: it hasn't.

Multi-locale sites list every language, and each URL carries the hreflang alternates — including x-default — that {{cmsHead}} already emits in the page head, so the two agree about which URLs exist:

<url>
  <loc>https://example.com/about</loc>
  <xhtml:link rel="alternate" hreflang="en" href="https://example.com/about"/>
  <xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/about"/>
  <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/about"/>
</url>

Turning it on advertises it. If you have written a robots.txt, a Sitemap: line pointing at it is added to what gets served — unless your file already names a sitemap, in which case yours is left alone. An empty robots.txt box stays empty: the sitemap does not make the CMS start claiming /robots.txt.

Three more things worth knowing:

  • New sites get it; upgrades don't. SeedAdmin turns it on for a brand-new site, exactly as it starts one in development mode. An existing site is left alone — if your app already serves its own /sitemap.xml, an upgrade must not quietly take the address over. Turn it on in the dialog when you want the CMS's.
  • A site in development publishes none. It is asking not to be crawled; handing out a list of every URL it has is the opposite of that. The switch stays where you left it and takes effect at the production flip.
  • The document is cached for five minutes. A page published a moment ago may not appear until then. That is a bound on cost, not a freshness promise — crawlers refetch on their own far slower schedule. The URLs come from Config.SiteURL when you set one, and otherwise from the requesting host, so an install reached by several names answers each with its own.

Past 50,000 URLs — pages × locales — the extra pages are left out and a warning is logged. Splitting into a sitemap index is the fix, and does not exist yet.

Host data in CMS pages

Some of a page isn't content. A dealership's "fresh on the lot" strip, a shop's best sellers, a clinic's next available appointments — those come from the host's own tables, change when the data changes rather than when someone rewrites a sentence, and have fields no arrangement of text slots models honestly. Config.TemplateFuncs lets page templates call the host's own functions alongside the cms* ones, so one page can mix both:

Start with the type the templates want. Returning display-ready strings keeps {{.Price}} in the template instead of a pipeline of formatting funcs:

type Vehicle struct {
    Name, Detail, Price, Terms, PhotoURL, URL string
}

type VehicleStore struct{ db *sql.DB }

func (s *VehicleStore) Featured(ctx context.Context, n int) []Vehicle { ... }
func (s *VehicleStore) Count(ctx context.Context) int                 { ... }

Register as many functions as the site needs — it is a FuncMap, so a page may call several:

vehicles := &VehicleStore{db: db}

c, err := cms.New(cms.Config{
    // ...
    // Declares the names page templates may call. These implementations
    // are used as-is by any render that supplies no replacement.
    TemplateFuncs: template.FuncMap{
        "featuredVehicles": func(n int) []Vehicle { return vehicles.Featured(context.Background(), n) },
        "vehicleCount":     func() int { return vehicles.Count(context.Background()) },
    },
    // Optional: rebind the same names per request, so each query carries
    // that request's context and is cancelled when the visitor leaves.
    RequestFuncs: func(r *http.Request) template.FuncMap {
        return template.FuncMap{
            "featuredVehicles": func(n int) []Vehicle { return vehicles.Featured(r.Context(), n) },
            "vehicleCount":     func() int { return vehicles.Count(r.Context()) },
        }
    },
})

In the template they look like any other func, and sit happily beside the cms* ones — the heading is content, the cards are data:

<h2>{{cmsText "inventory-title"}}</h2>          <!-- marketing owns the words -->
<p>{{cmsText "inventory-lede"}}</p>
<p>{{vehicleCount}} on the lot right now</p>

<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
  {{range featuredVehicles 3}}
    <a href="{{.URL}}">
      <img src="{{.PhotoURL}}" alt="{{.Name}}" loading="lazy">
      <h3>{{.Name}}</h3>
      <p>{{.Detail}}</p>
      <span>{{.Price}}</span> <span>{{.Terms}}</span>
    </a>
  {{else}}
    <p>Nothing on the lot right now — check back shortly.</p>
  {{end}}
</div>

An editor cannot type over a card, and nobody has to remember to edit the home page when a car sells. Give {{range}} an {{else}} branch: an empty result is a normal state, and without one the section renders as a heading over nothing.

Rules of thumb:

  • TemplateFuncs declares the names. Page templates are parsed against it, so every function a template calls must appear there — a name that doesn't fails at startup with function "featuredVehicles" not defined. RequestFuncs only replaces implementations for one render: names it omits keep their declared version, and a name appearing only there is unreachable, because no template could have compiled a call to it. New refuses RequestFuncs without TemplateFuncs for that reason.
  • The cms* prefix is reserved. New rejects a host func whose name starts with cms, so a later release can add template funcs without silently losing to (or shadowing) a host's.
  • A func registered only in TemplateFuncs is shared by every render and must be safe for concurrent use. Anything needing per-request state belongs in RequestFuncs.
  • They run with the host's full trust. A func returning template.HTML bypasses the editor's content sanitizer entirely, so never interpolate untrusted input into one.
  • Their markup lives in your template files, which your Tailwind build already scans — so data-driven markup needs no safelisting and never involves the generated content stylesheet.
  • Host funcs are bound in the admin's page and post previews too, so a preview renders what a visitor gets.

Hosts that validate templates at build time with render.CheckTemplate should switch to render.CheckTemplateFuncs, passing the same map, or every template calling a host func reports a spurious "function not defined".

examples/wheels shows the whole arrangement: vehicles.go is the store and the two maps, and templates/pages/home.gohtml ranges over the result between two cmsText slots.

Roles & permissions

Accounts have one of three roles, which encode trust:

  • editor — works on content, gated by per-user permissions (below).
  • admin — everything: all permissions implicitly, plus site-wide and per-page CSS/JS (written into pages unsanitized), the custom-code block library, and user management without restriction.
  • superadmin — admin plus snippet management (snippets are raw HTML injected into every editor), unlisted page templates in the new-page dialog, the development/production switch (whether the site may be indexed at all), and the admin panel's Pages section (everyone else works on pages in place on the public site, where every page feature is available; the admin list is the superadmin's index of pages that aren't linked anywhere).

What an editor may work on is a set of per-user permissions, toggled on their page under Users:

Permission Grants
Blog posts the blog feed: creating, editing, publishing blog posts
News the news feed, the same way
Pages, menus & site settings site pages, navigation menus, and the non-code site settings, all through the in-place editor (the site mode stays superadmin-only)
User management managing editor accounts (see below)

Everything follows from the grant: nav entries and dashboard cards the user can't act on don't render, the routes 403 regardless, and on the public site the in-place editor appears only on pages the user may edit — a blogs-only editor gets the editor (and sees drafts) on blog/… pages and the ordinary published render everywhere else. The media library stays open to every logged-in user; content work needs it.

A migration grants existing editors the three content permissions, so an upgrade changes nothing until you start unticking boxes.

User management without the admin role is deliberately bounded. An editor with that grant manages editor accounts only: admin accounts are out of reach entirely (including their passwords and two-factor resets), the role select offers only "editor", and they can neither grant nor revoke a permission they don't hold themselves. Escalation by way of the users page is a dead end.

Deployments can declare custom permissions for functionality they gate themselves — either implicitly through an admin section's Permission field (see Custom admin pages), or standalone:

Permissions: []cms.PermissionDef{
    {Key: "vehicles", Label: "Manage vehicles"},
},

Each declared permission becomes a checkbox on the user form; grants live in the cms_user_permissions table. Check them in your own handlers with auth.User.Can — inside an admin section that's admin.UserFrom(r).Can("vehicles") (admin roles always pass). Keys are lowercase identifiers ([a-z][a-z0-9_-]*, max 64 chars); the built-in keys blogs, news, pages, and users are reserved.

A permission can also be made to bind admins: declare it (or its section) with AdminsNeedGrant: true and the admin role stops holding it implicitly — the checkbox must be ticked for an admin exactly as for an editor, and only superadmins hold it regardless. Check these in your own handlers with auth.User.HasGrant rather than Can. Every declaration of one key must agree on the flag; cms.New refuses a mismatch.

Custom admin pages

Deployments often need admin pages the CMS doesn't ship — reports, imports, integration settings. Register them with Config.AdminSections: each section is a plain http.Handler the CMS mounts at {AdminPath}/x/{Path}/ inside the admin's middleware chain, so login enforcement, sessions, CSRF validation, and security headers are guaranteed — a custom page can't accidentally ship without them.

AdminSections: []cms.AdminSection{
    {Path: "reports", NavLabel: "Reports", Handler: reportsHandler},
    {Path: "billing", NavLabel: "Billing", AdminOnly: true, Handler: billingHandler},
},
  • Path — one URL segment of letters, digits, or - . _ ~; the section root is served at {AdminPath}/x/reports/ (the /x/ namespace guarantees host sections never collide with built-in admin routes, now or after upgrades). The bare URL without the trailing slash redirects to the slashed form, so relative links inside the section resolve correctly.
  • NavLabel — adds a link to the admin top bar; leave empty for routable-but-unlisted pages.
  • NavAfter — places the nav link directly under a built-in sidebar entry: "dashboard", "pages", "posts", "media", "snippets", or "users". Leave empty for the default position, after the built-in entries. Sections naming the same anchor keep their registration order — the wheels example anchors all six of its sections to "dashboard", so the dealership's own tools top the sidebar.
  • NavCount — a func(context.Context) (int, error) supplying the number beside the nav link, with the same leader line as the built-in entries. Called on every admin page render, so keep it a cheap query; an error is logged and renders as zero. Leave nil for no count — right for links that trigger an action rather than open a list. The wheels example counts its vehicles, active sales people and staff, leads, and push-ready feeds this way (navcounts.go).
  • Dashboard — puts a card for the section on the admin dashboard, ahead of the built-in cards, linking to the section root. A cms.DashboardCard carries a Title (defaults to NavLabel), a one-line Description, a Count func run once per dashboard render (defaults to NavCount; the same error-renders-as-zero contract), and an optional Note func supplying a short dynamic line under the description — freshness or urgency in the host's words ("Oldest undelivered: 2 days"; errors log and show nothing). Give the card the number that asks for attention — the wheels example's Vehicles card counts what still needs making ready, while its nav link counts the whole lot. Visibility follows the section's own rules (AdminOnly, Permission, AdminsNeedGrant).
  • AdminOnly — editors get 403 and no nav link.
  • Permission — restricts the section to users holding the named permission (see Roles & permissions). Naming a built-in permission reuses it; any other key declares a custom permission, which appears as a checkbox on the admin's user form, labelled with NavLabel. The wheels example gates its inventory manager this way: {Path: "inventory", NavLabel: "Inventory", Permission: "vehicles", Handler: ...}.
  • AdminsNeedGrant — makes Permission bind the admin role too: admins see and open the section only with the grant ticked on their user page, exactly as editors do; superadmins always pass. Several sections may share one permission key to switch on and off together — the wheels example groups its Sales people and Staff sections under a single "team" grant this way.
  • Handler — sees section-relative paths (/ at the root), so it can serve sub-routes and its own static assets beneath it.

cms.New rejects a config with malformed, duplicate, or handler-less sections; call admin.ValidateSections yourself to fail even earlier (e.g. in a test).

Inside a handler, four helpers from github.com/tsawler/cms/admin integrate with the admin UI:

Helper Purpose
admin.UserFrom(r) the logged-in *auth.User (never nil in a section)
admin.CSRFToken(r) token for the csrf_token field in POST forms
admin.SetFlash(r, msg) one-time message on the next admin page load
admin.RenderPage(w, r, title, body) wrap trusted HTML in the admin chrome
func reportsPage(w http.ResponseWriter, r *http.Request) {
    user := admin.UserFrom(r)   // logged-in *auth.User, never nil here
    body := fmt.Sprintf(`<h1>Reports</h1>
        <p>Hello %s.</p>
        <form method="post" action="refresh">
            <input type="hidden" name="csrf_token" value="%s">
            <button type="submit" class="cms-btn">Refresh</button>
        </form>`,
        template.HTMLEscapeString(user.Name),
        template.HTMLEscapeString(admin.CSRFToken(r)))

    // Wraps body (trusted host HTML) in the admin chrome: top bar,
    // nav, flash messages, stylesheet.
    admin.RenderPage(w, r, "Reports", template.HTML(body))
}

func refresh(w http.ResponseWriter, r *http.Request) {
    // CSRF was already validated before this ran.
    admin.SetFlash(r, "Refreshed.") // shown on the next admin page load
    http.Redirect(w, r, admin.SectionPath(r), http.StatusSeeOther)
}

Things to know:

  • POST forms need the CSRF token. The admin middleware rejects unsafe methods without it; include admin.CSRFToken(r) as the csrf_token field (or an X-CSRF-Token header from JS).

  • No inline scripts or styles. The admin serves a strict Content-Security-Policy without unsafe-inline; serve JS/CSS as files from the section's own handler. With an embedded FS that's one route:

    //go:embed assets
    var assetsFS embed.FS
    
    mux := http.NewServeMux()
    mux.Handle("GET /assets/", http.FileServerFS(assetsFS))
    // referenced from the page as <script src="assets/app.js" defer></script>
    
  • Redirect with full paths. The handler sees stripped paths, so http.Redirect with a relative URL resolves against the wrong base; use admin.SectionPath(r), the section's browser-facing base URL (e.g. /admin/x/reports/), appending a segment for sub-routes.

  • admin.RenderPage always writes 200 and inserts body unescaped — it's your code's HTML, escape any user data you interpolate into it. For other status codes or a fully custom look, write the response directly; the CSS classes in admin.css (cms-btn, cms-card, cms-muted, ...) are available either way.

The example app registers a working section — see reportsSection in examples/basic/main.go.

For modifying built-in admin pages there is deliberately no template override mechanism (it would couple deployments to internal template data and break silently on upgrades). The supported paths are the existing configuration knobs (Snippets, EditorStyles, SectionStyles, ...) — and when those don't cover a need, a config option added to the CMS itself.

The dashboard

The admin's landing page is built for the people who use it daily:

  • Host section cards come first — whatever the deployment registered with AdminSection.Dashboard (above), visible by the section's own permission rules. For most editors these cards are the dashboard.
  • The built-in cards — Pages, Snippets, Users, Media — are superadmin-only. They are site plumbing; editors and admins do their content work in place on the public site. Blog & News renders for anyone holding a blogs or news grant.
  • A traffic chart shows the public site's page views for each of the last seven days (UTC), for every logged-in user, with the week's five most-viewed pages listed beside it.

The traffic numbers come from the CMS itself: serving a page to an anonymous visitor upserts a per-day, per-path counter in cms_page_views — no cookies, no IPs, no user agents stored, so there is nothing here a privacy policy needs a section for. Logged-in CMS users aren't counted (staff aren't traffic), and neither are crawlers that identify themselves. Migrate prunes counters older than ninety days on every startup.

Bot protection

The public site is read-only (GET/HEAD only), so the bot-facing surface is the admin login form — and the forgot-password form, when a Mailer is configured (see the next section). Three layers protect both:

  • Login throttling (always on): five failed attempts per email+IP in fifteen minutes, then 429 responses until the window passes.
  • Honeypot (always on): a visually hidden form field; anything that fills it gets the ordinary wrong-password error and a throttle strike.
  • CAPTCHA (opt-in): a proof-of-work challenge verified against a self-hosted Cap server — no third-party service, no tracking, and the widget script is served by your own Cap instance rather than a CDN.

To enable the CAPTCHA, run Cap (docker image tiago2/cap, see examples/basic/docker-compose.yml), open its dashboard, log in with the container's ADMIN_KEY, create a site key, and configure:

Pin the widget version. Set WIDGET_VERSION and WASM_VERSION on the Cap container rather than leaving them at latest. The widget is a browser dependency of the login page: on latest, an upstream release can change its API or its CSP requirements under a deployment that has not changed at all. The compose file pins known-good versions; treat a bump like any other dependency upgrade and log in against it before committing.

The login page's CSP also carries 'unsafe-eval', because Cap's instrumentation challenge calls eval(). It is scoped to that one page — every other admin page gets a strict default-src 'self' policy. Turning instrumentation off for the site key in the Cap dashboard removes the need for it, at the cost of the anti-automation layer.

c, err := cms.New(cms.Config{
    // ...
    Captcha: &cms.CaptchaConfig{
        URL:     "https://cap.example.com", // browser-facing Cap server
        SiteKey: "your-site-key",
        Secret:  "your-secret",
        // InternalURL: "http://cap:3000", // optional: server-to-server
        //                                 // address, e.g. inside Docker
        // Visible: true,                  // optional: show Cap's checkbox
        //                                 // widget instead of solving the
        //                                 // challenge invisibly (default)
    },
})

With Captcha set, the login page solves the challenge invisibly in the background (Cap's programmatic mode) — users never see a CAPTCHA. Set Visible: true to show Cap's interactive checkbox widget instead. Either way the admin CSP is extended to admit exactly the Cap origin, and the login handler verifies the submitted token server-side before checking credentials. If the Cap server rejects the token, the login fails; if the Cap server is unreachable, the login proceeds with a logged warning — an outage of the CAPTCHA backend shouldn't lock admins out, and the throttle still applies. Host applications can reuse the verification client (captcha.New, Client.Verify) for their own forms.

Password resets ("forgot password")

The login page can offer a self-service reset: ask for a link, get an email, follow it, set a new password. The CMS owns the whole flow — the token table, the two pages, the throttling, and the wording of the email — and the host supplies exactly one thing: delivery.

// Satisfy the one-method interface with whatever your application
// already sends mail through:
type cmsMailer struct{ m *yourMailer }

func (a cmsMailer) Send(ctx context.Context, to, subject, text, html string) error {
    return a.m.Deliver(ctx, to, subject, text, html)
}

c, err := cms.New(cms.Config{
    // ...
    Mailer: cmsMailer{yourAppMailer},
})

That split is deliberate. Delivery policy — SMTP or an API, which From address, a development mail sink — already lives in the host, and the CMS should not duplicate it. The message content goes the other way: the CMS authors the email so every deployment sends the same carefully-worded thing, in particular the part that never confirms whether an address has an account.

Nil means off. With no Mailer configured, the login page shows no "Forgot your password?" link and the reset routes answer 404. A reset form that could never send its email would look broken; absent, the feature is honestly off. A host that wants the flow without real delivery (development, tests) can pass a Mailer that logs.

What the flow does, so you don't have to re-derive it from the code:

  • Tokens are single-use and expire after an hour (auth.ResetTTL). Asking again revokes the earlier link, so at most one works at a time. The database stores only a SHA-256 of the token — the email holds the only usable copy, so a leaked backup or a curious query replays nothing.
  • No account oracle. Every address gets the same confirmation page, and the email (when there is one) is sent in the background so known addresses are not measurably slower than unknown ones. Deactivated accounts get the same page and no email.
  • The same defenses as the login form: its own throttle (five requests per email+IP per fifteen minutes — this endpoint makes the server send email, which is worth as much to a spammer as a password guess is to a thief), the honeypot, and the CAPTCHA when one is configured.
  • A typo doesn't burn the link. Password validation failures re-render the form with the token intact; the token is only consumed once a valid new password is installed.
  • Both pages and the email are translated when the site has a French locale, like the rest of the admin.

The email link is built from Config.SiteURL when set, otherwise from the request's own scheme and host — the same rule as every other absolute link the CMS mints. If the admin is reached behind a proxy that rewrites Host, set SiteURL.

examples/wheels wires this up for real (see adminMailer in its mail.go): the adapter is five lines around the mailer the site already had, and it only sets Config.Mailer when mail is actually configured — so a fresh checkout without SMTP credentials gets the honest absent state rather than emails that vanish into a log.

Account settings and two-factor login

Every logged-in user has an account page at /admin/settings (their name in the sidebar links to it) with three things on it: editing their own name and email, changing their own password — behind the current one, so a walked-away session can't quietly take over the account — and turning two-factor login on or off.

Email edits get the same validation as the admin's user form (a well-formed address that no other account holds) and additionally require the current password, because the address is the login identifier and where reset links go. A name change alone needs no password. Role and active status are deliberately not on this page — nobody adjusts their own powers outside the admin-only /admin/users.

Two-factor uses ordinary TOTP authenticator apps (Google Authenticator, 1Password, Authy, …): the settings page shows a QR code and a manual-entry key, and enrollment only saves once a live code from the app confirms it — nobody locks themselves out by enabling it with an app that never scanned the code. From then on, the password step of login parks the user at a 6-digit code challenge; the session only exists after the code passes. Nothing to configure: the feature is per-user and always offered.

What the flow does, so you don't have to re-derive it from the code:

  • Codes are single-use. Each accepted code's 30-second time step is recorded (totp_last_step), and a login only succeeds by moving it forward — a shoulder-surfed or phished code replays nothing. One step of clock skew either side is accepted, like everything else that speaks TOTP.
  • The challenge is throttled like the login form (five wrong codes per account+IP per fifteen minutes), and it expires: a correct password opens a five-minute window to produce the code, then the half-login goes stale.
  • Turning it off requires the password — a borrowed session alone can't strip the second factor.
  • Lost phone? An admin editing the user (/admin/users/…) gets a "Reset two-factor authentication" checkbox; the user logs in with just their password and can re-enroll.

Running the examples

There are two, and they are deliberately opposites — start with whichever matches how you intend to build.

examples/basic examples/mariadb
Database Postgres (MySQL/MariaDB by profile) MariaDB
Styling Tailwind, compiled via go generate hand-written CSS in the layout
Build step needs the tailwindcss CLI none
Also shows media library, login CAPTCHA, blog & news, a custom admin page overriding SectionStyles/EditorStyles for a non-Tailwind host
Ports app 4000, db 5433 app 4200, db 3309

They use different ports and separate compose projects, so both can run at once.

The basic example
cd examples/basic
docker compose up -d      # Postgres on localhost:5433, Cap on localhost:3300
go run .

The compose file also carries MySQL and MariaDB, started only when asked for by profile. To run the example against one of them:

docker compose --profile mysql up -d      # MySQL on localhost:3307
CMS_DIALECT=mysql go run .

docker compose --profile mariadb up -d    # MariaDB on localhost:3308
CMS_DIALECT=mysql DATABASE_URL='cms:cms@tcp(localhost:3308)/cms?parseTime=true&loc=UTC&time_zone=%27%2B00%3A00%27&clientFoundRows=true' go run .

CMS_DIALECT=mysql covers MariaDB too — the two share one dialect. The example supplies the required DSN settings when DATABASE_URL is unset; if you set it yourself, include them all (see MySQL and MariaDB DSN settings).

To try the login CAPTCHA: open http://localhost:3300, log in with the ADMIN_KEY from docker-compose.yml, create a site key, and set CAP_URL=http://localhost:3300, CAP_SITE_KEY, and CAP_SECRET in the environment or .env. Without them the example runs without CAPTCHA. The challenge is solved invisibly by default; CAP_WIDGET=visible shows the checkbox widget instead.

Then open http://localhost:4000/admin/ and log in with admin@example.com / password123 (development defaults; override with CMS_ADMIN_EMAIL and CMS_ADMIN_PASSWORD).

Login sessions end when the browser closes unless "Remember me" is ticked, which keeps the login for cms.Config.RememberFor — 30 days by default, or CMS_REMEMBER_DAYS (measured in days) when set.

Both examples call SeedHomePage after SeedAdmin, so a fresh database serves a published page at / rather than a 404. It is a no-op once the site has any content — see Quick start.

The MariaDB example

The smallest host that does something real: no Tailwind, no build step, no object store, and a hand-written stylesheet in templates/base.gohtml.

cd examples/mariadb
docker compose up -d      # MariaDB on localhost:3309
go run .

Then http://localhost:4200/admin/, same development credentials. Its own README covers the one thing a non-Tailwind host has to know: the built-in SectionStyles and EditorStyles are Tailwind class names, so a plain-CSS site overrides both with classes its own stylesheet defines.

Environment variables

The example loads variables from a .env file — examples/basic/.env first, falling back to one at the repo root — without overriding anything already set in the real environment.

They come in two groups, and the difference matters as soon as you copy from here into your own site: five are the example's own program logic, and the rest are read by cms.ConfigFromEnv, which any host can call to fill those Config fields.

The example's own

The module never reads these. It takes its database from Config.DB — a *sql.DB you opened yourself — and its engine from Config.Dialect, so CMS_DIALECT is only how this program decides which driver to open and which default DSN to use. Naming these variables in your own main.go is a convention worth keeping, not something you inherit: sites generated by cms init read DATABASE_URL, ADDR, CMS_ADMIN_EMAIL and CMS_ADMIN_PASSWORD, but bake the driver in at generation time and have no CMS_DIALECT.

Variable Default Purpose
CMS_DIALECT postgres Database engine: postgres, or mysql for both MySQL and MariaDB. Selects the driver the example opens and the default DATABASE_URL, and is passed through to Config.Dialect.
DATABASE_URL Postgres: postgres://cms:cms@localhost:5433/cms?sslmode=disable; MySQL: cms:cms@tcp(localhost:3307)/cms?parseTime=true&loc=UTC&time_zone='+00:00'&clientFoundRows=true Connection string, matching docker-compose.yml. A MySQL DSN you supply yourself must carry all four settings in the default.
ADDR :4000 HTTP listen address.
CMS_ADMIN_EMAIL admin@example.com Email for the admin account seeded on first run.
CMS_ADMIN_PASSWORD password123 Password for that seeded admin account.
Read by the module

Everything cms.ConfigFromEnv fills in. An unset variable leaves its field zero and New applies the usual default; a set-but-malformed one is a startup error rather than a silent fallback.

Variable Default Purpose
CMS_REMEMBER_DAYS 30 How long a "Remember me" login lasts, in days. An invalid or non-positive value is a startup error.
CMS_SESSION_REDIS_ADDR unset (sessions stay in the database) Redis server address, host:port. Setting it moves login-session storage from the cms_sessions table to Redis (keys prefixed cms_session:) and makes the other CMS_SESSION_REDIS_* variables relevant.
CMS_SESSION_REDIS_PASSWORD unset (no auth) Password for that Redis server.
CMS_SESSION_REDIS_DB 0 Redis logical database number. An invalid or negative value is a startup error.
CMS_SITE_URL unset (each request's own host) The site's canonical public address, e.g. https://example.com. Used wherever a link has to work away from the page it was made on: the media library's Copy link, RSS item links, and hreflang alternates. Set it when the request's Host would be wrong — behind a proxy that rewrites it, or when the admin is reached by a different name than the public site. A value with no scheme is taken as https.
CMS_POSTS_PER_PAGE 10 How many posts a paginated {{cmsFeed}} listing shows per page. An invalid or non-positive value is a startup error. A template can override it per listing with {{cmsFeed "blog" 6}}.
CMS_ADMIN_PER_PAGE 25 How many rows a paginated admin list shows per page (Blog & News, and Pages). Separate from CMS_POSTS_PER_PAGE: an editor's table wants more rows than a public listing. An invalid or non-positive value is a startup error.
CMS_MEDIA_WEBP_QUALITY 0.3 Lossy WebP quality for image variants, in (0, 1]. A non-numeric value is a startup error.
CMS_MEDIA_MAX_VIDEO_MB 512 Video upload size cap in MB. A non-numeric value is a startup error.
CMS_MEDIA_ADOPT when-empty Whether Migrate rebuilds the media library from the object store: when-empty (only when the database holds no media), reconcile (check every startup), or off. Any other value is a startup error.
CMS_TAILWIND_COMMAND unset (rebuilds disabled) Content-driven Tailwind rebuild command: a space-separated argv with {content} and {output} placeholders (see Generated CSS for content classes and tailwind-content.sh).
CMS_TAILWIND_DIR unset Working directory for CMS_TAILWIND_COMMAND.
S3_ENDPOINT unset (media library disabled) S3-compatible object-store endpoint. Setting it enables the media library and makes the other S3_* variables relevant.
S3_BUCKET Bucket for uploaded media.
S3_ACCESS_KEY Object-store access key.
S3_SECRET Object-store secret key.
S3_REGION derived from the endpoint Region, if your provider needs it spelled out.
S3_KEY_PREFIX unset Prefix that namespaces this site's keys inside a shared bucket. It also scopes media adoption and the public-read policy, so set it whenever the bucket is shared.
S3_APPLY_PUBLIC_POLICY unset Set to 1 to apply a public-read bucket policy during Migrate (one-time setup; idempotent).
CAP_URL unset (CAPTCHA disabled) Browser-facing URL of the Cap server. Setting it enables the login CAPTCHA and makes the other CAP_* variables relevant.
CAP_SITE_KEY Site key created in the Cap dashboard.
CAP_SECRET Secret for that site key.
CAP_INTERNAL_URL unset (uses CAP_URL) Server-to-server Cap address, e.g. inside Docker.
CAP_WIDGET unset (invisible challenge) Set to visible to show Cap's checkbox widget on the login form.

Working on the in-place editor

The editor script served to logged-in editors is a generated bundle: editor/editor.js is built from the ES modules in editor/src/ (plus styles.css/light.css) and committed, so consumers of the module never need Node or a bundler — go get and go:embed keep working as before.

Think of editor/src/ as source code and editor/editor.js as its compiled output, like .go files and a binary. go generate ./editor is the compile step — Go never runs it for you, so it's part of the edit-build-run loop whenever the editor's source changes.

When to run go generate ./editor: after editing any file under editor/src/ — and only then. Changes to Go code, templates, or admin files don't involve the bundle. Consumers of the module never run it at all; the committed editor.js ships ready-made.

The full contributor loop, from the repo root:

# 1. change something in the editor's source
$EDITOR editor/src/dialogs.js

# 2. regenerate the committed bundle
go generate ./editor

# 3. restart the dev server — the bundle is embedded at compile
#    time, so a running server keeps serving the old one
cd examples/basic && go run .

# 4. commit the source change and the regenerated bundle together
git add editor/src/dialogs.js editor/editor.js

When iterating, skip the repeated step 2 and leave a watcher running in a spare terminal instead (you still restart the server to embed the result):

go run -C editor/build . -watch       # rebuild on every save

The trap to know about: forgetting to regenerate is silent. Everything still compiles and runs — it just serves the stale bundle, and your src/ change doesn't appear in the browser. If the editor is ignoring an edit you're sure you made, a missed go generate ./editor (or a missed server restart) is the likely cause.

The build tool is a nested Go module (editor/build) wrapping esbuild's Go API, so it needs only the Go toolchain and stays out of the cms module's dependency graph. Don't edit editor/editor.js by hand; it is overwritten by the next build (and marked linguist-generated).

Notes for host applications

  • Style your rich regions. The CMS never injects styles into your pages, so headings, lists, and blockquotes created by editors look however your CSS says. With Tailwind this matters: Preflight resets h1–h6/ul/blockquote to plain text, so give rich regions typography styles (e.g. the @tailwindcss/typography plugin's prose class, as examples/basic does) or editors' formatting will be invisible.
  • Safelist the editor's style classes — see The Styles menu above; skipping this makes applied styles silently invisible in production Tailwind builds.
  • Template file extensions are the host's choice (.gohtml plays best with editor tooling) — the CMS loads whatever paths you configure. But pages store their template's path, so renaming template files under an existing database needs a one-time fixup, e.g. UPDATE cms_pages SET template_name = replace(template_name, '.tmpl', '.gohtml');
  • All tables are prefixed cms_, so the CMS can share a database with the host app.
  • Login sessions can live in Redis instead of the database. Set Config.Redis (or CMS_SESSION_REDIS_ADDR) and sessions move from the cms_sessions table to Redis under cms_session: keys, with Redis's own key expiry replacing the hourly cleanup sweep. Everything else stays in the database; leave it unset and sessions do too.
  • Migrate is safe to run on every startup and from multiple instances concurrently. On MySQL and MariaDB it is not transactional — see Schema.
  • Config.ObjectStore replaces S3 entirely. Implement media.ObjectStore (Put/Get/Delete/PublicURL) and the media library uses it instead of a bucket — local disk in development, or any storage you already run. When set, Config.S3 is ignored.
  • A few Config fields have no environment variable and are easy to miss: SessionLifetime (default 24h; RememberFor extends it for "Remember me"), Logger (defaults to slog.Default()), and ObjectStore above. The struct's godoc documents every field.
  • SVG uploads are accepted as images (they act as their own renditions at every size — no rasterizing). Because an SVG viewed directly is a document that can run scripts, uploads are rejected unless they are free of active content (<script>, <foreignObject>, on* attributes, javascript:/non-image data: hrefs, DTD internal subsets), and the media proxy serves image/svg+xml with a script-blocking Content-Security-Policy. If you serve media straight from a public bucket/CDN instead of the proxy, that header is in your hands — configure it there if SVG uploads concern you.
  • Set Config.SecureCookies = true in production (HTTPS).
  • Config.AdminPath (default /admin) is where Handler() serves the admin area. If you wire Admin() yourself instead, the mount point must match it.

License

This project is licensed under the Mozilla Public License 2.0 — see LICENSE.

MPL-2.0 is file-level copyleft: you can import this module into an application under any license, including a closed-source or commercial one, and mounting the CMS in your app does not affect how you license that app. What the license asks is that if you modify this project's own source files and distribute the result, those modified files stay under MPL-2.0 and their source is made available.

Third-party components

Bundled and imported components keep their own licenses:

  • TinyMCE 6 (editor/tinymce/) — MIT, vendored and self-hosted; this is the last MIT-licensed release, and the version bundled here is deliberately pinned to it. See editor/tinymce/license.txt. Its link/unlink glyphs are also used by the editor's own rich-text field (editor/src/dialogs.js), so the two toolbars show the same icons.
  • pako 2.1.0 (admin/static/pako_inflate.min.js) — MIT AND Zlib, vendored for the Cap proof-of-work captcha; the license banner is in the file itself.
  • Go dependencies are MIT, BSD, Apache-2.0, or MPL-2.0 — see go.mod and each module's own license.

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.

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) 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) 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.

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.

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.

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, and hreflang alternates.
	//
	// Optional. 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. Set it when that guess would 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

	// 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 it is only sent
	// over HTTPS. Enable in production; leave off for local development
	// over plain HTTP.
	SecureCookies bool

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

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

	// 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). Nil gets the Tailwind-first defaults
	// (snippets.DefaultSnippets plus snippets.DefaultSectionPresets); an
	// empty non-nil slice ships none. Snippet classes need safelisting
	// like editor styles do.
	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

	// 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)

ConfigFromEnv returns a Config populated from the environment:

  • CMS_SITE_URL → SiteURL, the site's canonical public address ("https://example.com"). Leave it unset in development, where each request's own host is right.
  • S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET, S3_KEY_PREFIX, S3_APPLY_PUBLIC_POLICY (=1) → S3. Setting S3_ENDPOINT enables the media library.
  • CAP_URL, CAP_INTERNAL_URL, CAP_SITE_KEY, CAP_SECRET, CAP_WIDGET (=visible) → Captcha. Setting CAP_URL enables the login CAPTCHA.
  • CMS_SESSION_REDIS_ADDR, CMS_SESSION_REDIS_PASSWORD, CMS_SESSION_REDIS_DB → Redis. Setting CMS_SESSION_REDIS_ADDR moves session storage from the cms_sessions table to Redis.
  • CMS_REMEMBER_DAYS → RememberFor, in days.
  • CMS_POSTS_PER_PAGE → PostsPerPage, how many posts a paginated blog or news listing shows on one page.
  • CMS_ADMIN_PER_PAGE → AdminPerPage, how many rows a paginated admin list shows on one page. Separate from CMS_POSTS_PER_PAGE, which sizes the public listing.
  • CMS_MEDIA_WEBP_QUALITY → MediaWebPQuality.
  • CMS_MEDIA_MAX_VIDEO_MB → MediaMaxVideoMB.
  • CMS_MEDIA_ADOPT (when-empty | off | reconcile) → MediaAdopt, which decides whether a bucket that already holds media is adopted into an empty database. Setting S3_KEY_PREFIX is what makes that safe on a bucket shared with other sites.
  • CMS_TAILWIND_COMMAND (a space-separated argv with {content} and {output} placeholders), CMS_TAILWIND_DIR → Tailwind.

An unset variable leaves its field zero, so New applies the usual defaults; a set-but-malformed value is a configuration mistake and returns an error. Everything without an environment variable — DB, TemplateFS, PageTemplates, AdminSections, ... — is left for the host to fill in:

cfg, err := cms.ConfigFromEnv()
if err != nil { ... }
cfg.DB = pool
cfg.TemplateFS = templateFS
c, err := cms.New(cfg)

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 helpers shared by the admin area and the public handler (which needs to recognize logged-in editors for in-place editing).
Package sessiondata holds the session keys and helpers shared by the admin area and the public handler (which needs to recognize logged-in editors for in-place editing).
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