Theme Settings

Editor-driven, per-page theme configuration with full custom-field support.

What this is

Theme settings are an editor-friendly way to expose theme configuration without inventing site-settings keys. You declare settings pages in theme.json; each page gets a JSON schema file describing its fields; the admin renders one editor screen per page under /admin/theme-settings/<page>; templates and Tengo read values at runtime.

The boundary between site settings and theme settings is worth holding onto: site settings (core.settings.set) are cross-cutting kernel-level config (brand name, default language, security options, SEO defaults) that survives a theme switch and is editable independently of which theme is active. Theme settings are theme-shaped UI knobs (header style, footer columns, hero variant) that disappear when the theme is deactivated and reappear with the same values when re-activated. If you find yourself declaring a theme setting that should still be meaningful after switching themes, you wanted a site setting.

Declaring pages

List each settings page in theme.json's settings_pages array. The icon field is optional and accepts any Lucide icon name; it's used as the sidebar icon next to the page label.

themes/squilla/theme.json
"settings_pages": [
  { "slug": "brand",  "name": "Brand",  "file": "settings/brand.json",  "icon": "Sparkles" },
  { "slug": "footer", "name": "Footer", "file": "settings/footer.json", "icon": "AlignBottom" }
]

Per-page schema

Each page schema is an object with a fields array. Field definitions reuse the same vocabulary as block fields: key, type, label, required, default, help, placeholder, width, plus type-specific keys (options[] for select/radio, sub_fields[] for repeater/array, node_types[] for reference, etc.). The full list of types is the same 21 canonical kernel types covered in Authoring Blocks, plus any extension-contributed types active at the time.

themes/squilla/settings/brand.json
{
  "fields": [
    { "key": "primary_color", "type": "color",  "label": "Primary",  "default": "#1d4ed8" },
    { "key": "logo",          "type": "image",  "label": "Logo"      },
    { "key": "tagline",       "type": "text",   "label": "Tagline"   },
    { "key": "social",        "type": "repeater", "label": "Social links",
      "sub_fields": [
        { "key": "platform", "type": "select", "options": ["x","github","linkedin"] },
        { "key": "url",      "type": "url" }
      ] }
  ]
}

Reading from layouts and partials

The render context exposes .theme_settings.<page>.<key>. Each value comes back in its native shape — a string for text fields, a media object ({src, alt, width, height, ...}) for image fields, an array for repeaters — not pre-stringified.

layouts/default.html
{{- $brand := .theme_settings.brand -}}
<img src="{{ image_url $brand.logo.src "thumb" }}" alt="{{ $brand.logo.alt }}">
<p>{{ $brand.tagline }}</p>

{{- range $brand.social -}}
  <a href="{{ .url }}" data-platform="{{ .platform }}">{{ .platform }}</a>
{{- end -}}

Reading from blocks

Block view context excludes .theme_settings by design (blocks must render in standalone core.render.block previews where no theme settings exist). Use the themeSetting helper instead. Returns the same native-shape value the layout context would, or the field's default if unset, or zero value if there's no default.

blocks/cta/view.html
<section style="--brand: {{ themeSetting "brand" "primary_color" }}">
  <a href="{{ .cta.url }}">{{ .cta.label }}</a>
</section>

{{/* Read the entire page at once: */}}
{{- $brand := themeSettingsPage "brand" -}}
<img src="{{ image_url $brand.logo.src "thumb" }}">

Reading from Tengo

The core/theme_settings module exposes four functions:

  • active_theme() — returns the slug of the active theme, or "" if none.
  • pages() — returns an array of {slug, name, file, icon} objects for the active theme's declared settings pages.
  • get(page, key) — returns the stored value for one field, or zero value if unset.
  • all(page) — returns the entire page as a map of key → value.

Theme scripts get the theme_settings:read capability automatically (it's part of the default theme capability set in internal/scripting/capabilities.go). Extensions that want to read theme settings must declare theme_settings:read explicitly.

scripts/setup/seed_home.tengo
ts := import("core/theme_settings")

logo    := ts.get("brand", "logo")           // media-object
tagline := ts.get("brand", "tagline")        // string
brand   := ts.all("brand")                   // map of all fields

// Use it in a seed:
log := import("core/log")
if tagline != "" {
  log.info("seeding home page with tagline: " + tagline)
}

Storage and lifecycle

Theme settings are stored in the kernel's site_settings table with keys of the form theme:<slug>:<page>:<field_key> (e.g. theme:hello-vietnam:header:logo). Strings, numbers, booleans, and arrays are stored serialised; image fields persist the JSON of the media object. Activation snapshots the schema in process memory and registers the per-page settings handler; updating theme.json or any settings/*.json requires a re-activation (or core.theme.rescan to pick up new pages without a full activate cycle).

Field-type changes do not migrate values. If you change a field from text to select, the previous text values stay verbatim in the database and the admin will render an empty select for any node whose stored value isn't in the new options list. Plan field changes carefully: rename to a new key when the meaning changes, or write a one-shot Tengo migration that reads the old key, transforms the value, and writes the new key before removing the old one.

Sensitive fields

Set "sensitive": true on a field (most useful for text with API tokens) to opt into the kernel's encrypt-on-write behaviour. Sensitive values are encrypted at rest using the AES-256 master key from SQUILLA_SECRET_KEY and decrypted only when read by an authorised template or script. The admin obscures the value with dots in the editor; submit a non-empty value to update, leave blank to keep the existing one.

Per-language settings

Theme settings are not per-language by default — a single value applies across every locale. To opt a field into per-language storage, set "translatable": true on the field declaration; the kernel will then store one row per language with fallback to the default language when the locale-specific row is empty (the same fallback behaviour the kernel uses for site settings, with the per-locale schema in migrations 0038–0040).