Themes Overview

Anatomy, lifecycle, and the layout-vs-partial-vs-block contract.

What's in a theme

A theme is a self-contained package under themes/<slug>/ that owns the public-facing presentation. It declares layouts, partials, content blocks, page templates, settings pages, image sizes, assets, optional forms templates, and a Tengo seed entry point. The kernel knows nothing about how your site looks — themes are the entire visual layer. Switch the active theme and the public site changes look without a single line of kernel code being recompiled or restarted.

Themes do not own data. They cannot ship SQL migrations, claim database tables, or call the data-store CoreAPI methods. If you need either of those, you wanted an extension. The boundary is sharp on purpose: a theme should be removable without leaving orphaned tables, and an extension should be removable without leaving orphaned CSS.

The bundled themes are default (a minimalist marketing-page starter and the kernel's fallback when no theme is active), squilla (the marketing site for squilla.app you're reading right now), hello-vietnam (a richer travel-blog reference), and curriculum-vitae (a CV/portfolio template). Use them as cribs.

filesystem
themes/<slug>/
  theme.json               # manifest
  layouts/*.html           # page-level templates (default, post, docs, error, 404, ...)
  partials/*.html          # reusable fragments rendered via {{renderLayoutBlock}}
  blocks/<slug>/           # content blocks: block.json + view.html [+ style.css + script.js]
  templates/*.json         # pre-built page block sequences for one-click insertion
  forms/*.html             # form layouts wired through forms-extension
  settings/*.json          # settings-page schemas (referenced from theme.json)
  assets/                  # CSS, JS, fonts, images shipped with the theme
  scripts/                 # Tengo
    theme.tengo            # entry point, runs synchronously on activation
    seeds/*.tengo          # split seed modules (pages, posts, taxonomies, menus)
    setup/*.tengo          # nodetypes, taxonomies, settings, menu structures
    filters/*.tengo        # filter handlers
    handlers/*.tengo       # event handlers

Activation

Calling core.theme.activate drives an in-process pipeline. The whole sequence is hot — nothing crosses a process boundary, nothing restarts.

  1. Parse theme.json and upsert layouts, partials, content blocks, image-size definitions, and per-settings-page snapshots into the kernel. The corresponding rows in layouts, content_block_types, etc. are tagged with source='theme' so the API knows they're theme-shipped (and therefore read-only) until detached.
  2. Synchronously publish the theme.activated event with the asset list. media-manager subscribes and imports declared assets[] images into the media library, tagged source='theme'; image-size definitions are upserted into media_image_sizes.
  3. Atomically swap the theme-asset URL resolver so /theme/assets/* requests resolve against the new theme's assets/ directory. Old asset URLs return 404 instantly; new ones serve immediately.
  4. Load and execute scripts/theme.tengo. Top-level statements run synchronously and block activation until they complete — by design, so the seed has a chance to register node types, taxonomies, menus, and pages before the public site can render anything.

Deactivation reverses the asset wiring and unloads scripts, but does not destroy seeded content nodes. Re-activating a theme on a database that already has its seed nodes finds them by slug and either skips creation (idempotent seeds) or upserts updates.

DEPLOYING FROM OUTSIDE THE REPO
core.theme.deploy unpacks a base64-zipped package into data/themes/<slug>/ via an atomic directory swap. Inline base64 is capped at 50 MB. For larger archives use the presigned-upload pair (core.theme.deploy_init returns a token-bound PUT URL, core.theme.deploy_finalize routes through the same install pipeline) — default cap 200 MB, override via SQUILLA_THEME_MAX_MB. Both forms support activate=true for one-shot deploy + activate.

Layout vs partial vs block

This is the #1 thing that trips theme authors. The three template kinds see different data, and the differences are not symmetric.

  • Layouts are page-level. They see .app, .node, .user, .theme_settings, plus everything renderLayoutBlock exposes. A layout's job is to wrap a node's blocks_html in chrome.
  • Partials are reusable fragments included via {{renderLayoutBlock "slug"}}. Inside a partial you have the full layout context plus .partial.* — the partial's own field values resolved per-page from the node's layout_data. Partials are partial in the literal sense: they share the page's data and contribute one piece of it.
  • Block views are atomic content units. They see only the block's own field values at root. .app, .node, .user are not in scope. Theme settings reach blocks through the themeSetting / themeSettingsPage template helpers, which the renderer injects into the per-block FuncMap. The reason for the isolation: blocks must render correctly in core.render.block previews where no node and no user exist, and they're cacheable per-fields-hash, which would not work if they could read arbitrary page state.

Layout selection

The renderer (internal/cms/layout_svc.go) picks a layout for a node by walking this cascade until something matches. Always reaches an answer (the kernel-registered default layout from migration 0036 is the floor):

  1. node.layout_slug — explicit, portable across themes. Set on the node from the editor or the seed. This is the recommended way to pick a layout.
  2. node.layout_id — legacy numeric pointer; auto-synced to layout_slug on save.
  3. layout-<nodetype>-<slug> — language-scoped exact match, useful for one-off pages.
  4. layout-<nodetype> — language-scoped nodetype catch-all (e.g. all blog_post nodes use layout-blog_post).
  5. The first layout in the active theme with is_default: true.
DEFAULT LAYOUT
If no layout has is_default: true in theme.json and no node has an explicit layout_slug, the kernel falls back to its seed-shipped default layout (a minimal HTML5 skeleton). core.theme.checklist warns about this so you can make the fallback explicit before it surprises you on a real page.

Detach & reattach

Theme-shipped layouts and content-block types are read-only via the API. The layout service refuses to update source='theme' rows with a THEME_READONLY error so a stray admin save can't corrupt the on-disk source of truth. To edit a layout from the admin without forking the theme entirely, call core.layout.detach({ id }) to flip its source to 'custom'; the row becomes editable and its content is held in the database, decoupled from the file. core.layout.reattach({ id }) reverses that: the database row goes back to source='theme' and the on-disk template is the source of truth again. Any local edits made while detached are discarded — the operation is destructive in that direction, so back up first if it matters.

The same pair exists for content blocks: core.block_types.detach / core.block_types.reattach. Useful for one-off tweaks to a vendored theme without forking the whole repository.

Theme settings

Theme settings are an editor-friendly way to expose theme configuration without inventing site-settings keys. You declare settings pages in theme.json, each with its own JSON schema; the admin renders one editor screen per page; templates and Tengo read values at runtime via .theme_settings or the themeSetting helper. See Theme Settings for the full reference.