theme.json Reference

Every field the theme manifest accepts.

Required fields

  • name (string) — display name shown in the admin's themes list.
  • version (string) — semver string. Surfaced in admin and used as cache-bust tag for static assets.
  • description (string) — long-form description, used as the theme card body in the admin.
  • author (string).

The slug is implicit — it's the directory name under themes/ (or under data/themes/ for runtime-installed themes). core.theme.deploy validates that the package's directory name is a clean slug matching [A-Za-z0-9_-]+ before unpacking. Mismatched slugs are rejected at deploy time, not at activation.

Static assets

  • styles — array of { src }. Each entry is a relative path from the theme root, served at /theme/<slug>/<src> and emitted into the layout's {{ .app.head_styles }} on every render.
  • scripts — array of { src, position?: "head"|"body_end" }. head goes into head_scripts; body_end (default) goes into foot_scripts.

Layouts, partials, blocks, templates

Each is an array of declarations. Each declaration carries a slug, a human name, the relative path under the theme to the source file, and — where applicable — a field_schema. Block declarations can use dir to point at a directory containing block.json and view.html instead of inlining everything.

themes/squilla/theme.json
{
  "name":        "Squilla",
  "version":     "0.2.0",
  "description": "The marketing site theme.",
  "author":      "Squilla",

  "styles":  [ { "src": "styles/theme.css" } ],
  "scripts": [ { "src": "scripts/theme.js", "position": "body_end" } ],

  "layouts": [
    { "slug": "default", "name": "Default", "file": "layouts/default.html",
      "is_default": true, "field_schema": [] },
    { "slug": "docs",    "name": "Docs (3-column)", "file": "layouts/docs.html",
      "field_schema": [
        { "key": "sidebar_position", "type": "select", "options": ["left","right"], "default": "left" }
      ] }
  ],
  "partials": [
    { "slug": "site-header", "name": "Site header", "file": "partials/site-header.html",
      "field_schema": [
        { "key": "cta_label", "type": "text", "default": "Get started" },
        { "key": "cta_url",   "type": "url",  "default": "/signup"      }
      ] }
  ],
  "blocks": [
    { "slug": "hero", "name": "Hero", "dir": "blocks/hero" },
    { "slug": "sq-hero", "name": "Squilla hero", "dir": "blocks/sq-hero" }
  ],
  "templates": [
    { "slug": "homepage", "name": "Marketing homepage", "file": "templates/homepage.json" }
  ],

  "image_sizes": [
    { "slug": "thumb", "width": 320, "height": 240, "crop": "center" },
    { "slug": "hero",  "width": 1600, "height": 900, "crop": "smart"  },
    { "slug": "hero-2x", "width": 3200, "height": 1800, "crop": "smart" }
  ],
  "assets": [
    { "key": "logo",     "src": "assets/logo.svg",     "alt": "Squilla logo" },
    { "key": "hero",     "src": "assets/hero.jpg",     "alt": "Hero image"   }
  ],

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

image_sizes

Each entry declares a named crop the media-manager extension generates on upload. The shape is { slug, width, height, crop } where crop is one of center, smart (face/saliency-aware crop), or contain (no crop, fit within bounds and pad). Templates reach a generated size with {{ image_url .image.src "<slug>" }}; multiple sizes per image work via {{ image_srcset .image.src "thumb" "thumb-2x" }}. image_sizes is theme-only — extensions cannot declare image sizes (they live in theme.json because they're tied to the theme's specific layout assumptions).

assets

Each entry is { key, src, alt? }. On activation, media-manager imports the file at src into the media library and tags it source='theme'. Templates reach the imported media via theme-asset:<key> URLs (the renderer resolves these to the actual media URL at render time so the link survives a theme deactivation/reactivation that might rotate the underlying media id).

settings_pages

Each entry is { slug, name, file, icon? } where file is the relative path to a JSON schema declaring the page's fields. See Theme Settings for the per-page schema and the runtime read paths.

Field schema convention

This trips everyone up. Field schema entries on layouts, partials, and blocks use "key":. Field schema entries on node types registered through Tengo (core/nodetypes.register) use "name":. Mixing them produces empty admin inputs even when fields_data is correct, because the form renderer matches saved values against the wrong key.

key vs name
block.json / theme.json field_schema → "key". Tengo core/nodetypes.register field_schema → "name". The MCP tool core.guide(topic:'editing') has this fact in its gotchas table under field_schema_vocabulary; both shapes accept identical type strings (text, image, repeater, etc.), so type errors will not catch a key/name swap for you.

Validation

core.theme.checklist walks a theme and flags:

  • No layout has is_default: true — nodes without explicit layout_slug will fall back to the kernel's seed default.
  • Block field_schema keys not present in test_data (the admin previews will be broken).
  • Layouts referenced from templates/*.json that don't exist in theme.json.
  • Assets used in seed data (via theme-asset:<key>) without a matching assets[] declaration.
  • image_sizes referenced from image_url calls in templates that don't exist in the manifest.
  • 404 / error layout missing.
  • Each layout missing the kernel-managed head_meta emission.

Run it before shipping. The result is a list of {severity, code, message} entries you can iterate before declaring a theme done. core.theme.standards is the structurally similar tool that audits the manifest itself against the recommended shape (instead of the cross-file integrity checks).