Docs / Creating Themes

Creating Themes

This guide describes the recommended shape of a GoPress theme.

Minimal Theme

package mytheme

import (
    "html/template"
    "path/filepath"

    "github.com/gin-gonic/gin"
    "go-press/core"
    coreTheme "go-press/core/theme"
)

func init() {
    core.RegisterTheme("my-theme", func(engine *core.Engine, themeDir string) coreTheme.Theme {
        return New(engine, themeDir)
    })
}

type MyTheme struct {
    coreTheme.BaseTheme
    engine *core.Engine
}

func New(engine *core.Engine, themeDir string) *MyTheme {
    t := &MyTheme{engine: engine}
    t.InitBase(engine, themeDir, nil)
    t.LoadTemplates(t)
    return t
}

func (t *MyTheme) Name() string        { return "My Theme" }
func (t *MyTheme) Version() string     { return "1.0.0" }
func (t *MyTheme) Description() string { return "Example theme" }
func (t *MyTheme) Author() string      { return "Me" }
func (t *MyTheme) Setup(app coreTheme.App) {}
func (t *MyTheme) ServeHTTP(c *gin.Context) { t.BaseTheme.ServeHTTP(c) }
func (t *MyTheme) TemplateFuncs() template.FuncMap { return t.BaseFuncMap() }
func (t *MyTheme) TemplateDir() string { return filepath.Join(t.ThemeDir, "templates") }
func (t *MyTheme) StaticDir() string { return filepath.Join(t.ThemeDir, "static") }

No manual cmd/server/main.go edit is required. Drop the folder into themes/, make sure it has both theme.toml and at least one non-test .go file at its root, then re-run gopress serve. The autoload package is regenerated and the new theme's init() registers itself with core.RegisterTheme at startup. See Getting Started > Installation for details.

Theme Metadata

theme.toml is required β€” it both serves as the auto-detection marker (the gopress CLI ignores a themes/<name>/ directory without it) and supplies the content type and menu location declarations consumed by core. Minimum schema:

[theme]
name = "My Theme"
version = "1.0.0"
description = "Example theme"
author = "Me"

[[content_types]]
name = "product"
label = "Product"
label_plural = "Products"
archive_title_key = "page_title_product"
supports = ["title", "content", "excerpt", "thumbnail", "sort_order"]
taxonomies = ["category", "tag"]
has_archive = true
rewrite_slug = "products"
menu_icon = "blocks"
menu_order = 1

[[menu_locations]]
name = "header"
label = "Header Navigation"

Core types such as post and contact_message should not be redeclared by themes. product is only an example custom content type; GoPress does not require a theme to provide products, services, or showcases.

For frontend multilingual labels, add content_type.<name> entries to the theme locale files. BaseTheme uses those keys for content type badges on taxonomy archives and falls back to label when a locale key is missing:

{
  "content_type.product": "Product"
}

Rewrite Slugs And Template Mapping

rewrite_slug is the public URL base for a content type. The example above produces:

/products
/products/{content-slug}

When the visual template name differs from the content type name, add an explicit templates mapping instead of hard-coding routes in Go:

[[content_types]]
name = "module"
label = "Module"
label_plural = "Modules"
archive_title_key = "page_title_module"
supports = ["title", "content", "excerpt", "thumbnail", "sort_order"]
taxonomies = ["category", "tag"]
has_archive = true
rewrite_slug = "modules"
templates = { archive = "products", single = "product-detail" }
menu_icon = "blocks"
menu_order = 1

This keeps the content model (module), public URLs (/modules), and presentation templates (products, product-detail) independently configurable. It is useful when a theme reuses an existing layout for a differently named business concept. archive_title_key points to a theme locale key used for archive <title> and Open Graph title, so multilingual pages do not fall back to the static label_plural text.

Template Hierarchy

templates/
  layouts/base.tmpl
  partials/header.tmpl
  pages/home.tmpl
  pages/products.tmpl
  pages/product-detail.tmpl
  pages/archive.tmpl
  pages/single.tmpl

BaseTheme compiles templates/pages/*.tmpl as page bundles. For a product detail page named air-shower, it first tries page bundle names derived from the route and content type:

single-product-air-shower
single-product
product-detail
products-detail
<templates.single from theme.toml>
single

For a product archive with rewrite_slug = "products", it tries:

archive-product
products
product
<templates.archive from theme.toml>
archive

If no page bundle matches, BaseTheme falls back to the classic root-template hierarchy (archive-product.tmpl, single-product.tmpl, archive.tmpl, single.tmpl, index.tmpl) and finally to built-in fallback templates.

Inside templates, prefer core URL helpers:

<a href="{{archiveURL "product"}}">Products</a>
<a href="{{contentURL . "product"}}">{{.Title}}</a>

archiveURL and contentURL consult the rewrite registry, so a later rewrite_slug change or content-type rename does not require template edits.

Dynamic archive pages also honor query-string filters for taxonomies declared on the content type. For example, a post type with taxonomies = ["category", "tag"] can be filtered with /blog?category=industry-news or /blog?tag=cleanroom. Query parameters for taxonomies not registered on that content type are ignored.

For navigation active state, compare the current request URL with the menu item URL through core:

{{with menuByLocation "header"}}
  {{range .Items}}
    <a href="{{.URL}}" class="{{if isMenuURLActive $.Ctx .URL}}active{{end}}">{{.Title}}</a>
  {{end}}
{{end}}

Avoid hard-coded checks such as .ActivePage == "products" in reusable themes. Menu labels, content type names, and rewrite slugs are configuration, not theme code contracts.

Base Layout Contract

Every plugin-friendly theme should declare:

{{renderHook "theme.head.end" .}}
{{renderHook "theme.body.open" .}}
{{renderHook "theme.footer.end" .}}
{{renderHook "header.nav.after" .}}

Use pageTitleFor, seoHeadFor, settingOr, archiveURL, contentURL, isMenuURLActive, currentLang, langPrefixURL, menuByLocation, and the responsive image helpers from the core funcmap instead of implementing theme-local equivalents.

Dates And Site Timezone

Use formatDate and formatDateTime from BaseFuncMap() when rendering content publish times. These helpers read site_timezone from System Settings, convert UTC timestamps from the database into the site timezone, and then format the value for templates.

If a theme needs a custom date formatter, convert through engine.SiteLocation() before formatting:

func New(engine *core.Engine, themeDir string) *MyTheme {
    t := &MyTheme{engine: engine}
    t.InitBase(engine, themeDir, template.FuncMap{
        "formatLongDate": func(tm *time.Time) string {
            if tm == nil {
                return ""
            }
            return tm.In(engine.SiteLocation()).Format("2006-01-02")
        },
    })
    t.LoadTemplates(t)
    return t
}

This keeps the contract consistent across the admin, frontend, and sitemap path: inputs are parsed in the site timezone, stored as UTC, and displayed in the site timezone. Existing sites without site_timezone fall back to the server local timezone until an explicit value is saved.

Demo Data

Implement DemoSeedPath() to enable one-click demo import from the admin:

func (t *MyTheme) DemoSeedPath() string {
    return filepath.Join(t.ThemeDir, "demo", "data", "seed.toml")
}