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" }.headgoes intohead_scripts;body_end(default) goes intofoot_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.
{
"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". 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 explicitlayout_slugwill fall back to the kernel's seed default. - Block
field_schemakeys not present intest_data(the admin previews will be broken). - Layouts referenced from
templates/*.jsonthat don't exist intheme.json. - Assets used in seed data (via
theme-asset:<key>) without a matchingassets[]declaration. image_sizesreferenced fromimage_urlcalls in templates that don't exist in the manifest.- 404 / error layout missing.
- Each layout missing the kernel-managed
head_metaemission.
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).