Authoring Blocks

block.json, view.html, scoped CSS/JS, and the test_data invariant.

Anatomy of a block

A block lives under themes/<t>/blocks/<slug>/ and consists of two required files plus three optional ones. The kernel discovers blocks by scanning theme.json's blocks[] array; an entry can either inline its fields and template, or reference a directory with the dir key and let the loader read the files automatically.

  • block.json — slug, label, icon, field_schema, test_data, optional cache_output, optional group.
  • view.html — Go template that consumes the field values at root.
  • style.css — block-scoped CSS, injected via {{ .app.block_styles }} in the layout's <head> exactly once per page even when the block appears multiple times.
  • script.js — block-scoped JS, injected via {{ .app.block_scripts }}.
  • preview.svg / preview.png — optional thumbnail shown in the admin's block picker.

Extensions can ship blocks too — the bundled content-blocks extension is forty-plus block types alone (every cb-* entry in core.block_types.list). Block authoring is identical between themes and extensions; only the manifest field differs.

The field schema

Every field has a type, a key, and per-type configuration. Common shapes:

blocks/hero/block.json
{
  "slug": "hero",
  "label": "Hero",
  "icon": "Stars",
  "group": "Marketing",
  "cache_output": false,
  "field_schema": [
    { "key": "heading",   "type": "text",     "required": true, "width": "full" },
    { "key": "subhead",   "type": "textarea", "width": "full" },
    { "key": "image",     "type": "image",    "width": "half"  },
    { "key": "cta",       "type": "link",     "width": "half"  },
    { "key": "items",     "type": "repeater",
      "sub_fields": [
        { "key": "label",  "type": "text" },
        { "key": "icon",   "type": "text" }
      ]
    }
  ],
  "test_data": {
    "heading": "The CMS where AI does the work",
    "subhead": "Activate a theme. The CMS reorganises itself around it.",
    "image":   { "src": "theme-asset:hero", "alt": "Squilla" },
    "cta":     { "label": "Get started", "url": "/docs", "target": "_self" },
    "items":   [
      { "label": "50ms TTFB", "icon": "Zap" },
      { "label": "AI-native", "icon": "Sparkles" }
    ]
  }
}
key vs name
block.json field_schema entries use "key":. The same fields registered via Tengo core/nodetypes.register use "name":. Mismatching the two produces empty admin inputs even when fields_data is correct, because the form renderer matches saved values against the wrong key. Run core.guide and read the field_schema_vocabulary gotcha if you forget which is which.

The test_data invariant

Every key in field_schema must have a corresponding entry in test_data. The admin renders test_data as the live preview when an editor opens a block they haven't touched yet, and core.render.block uses it as the default payload when no fields argument is passed. A missing entry shows up as an empty area or a broken template; core.theme.checklist flags every block that's missing test data so you catch it before publishing.

The same applies in reverse: every key in test_data should appear in field_schema. Stray keys are harmless at render time but clutter the admin's preview state and trip up sanity audits.

No fallback values

If you write {{ .heading | or "default" }}, you're hiding a data bug.

Fallback values mask the case where seed data was supposed to populate the field but didn't. Pass real defaults via fields_data in the seed and let templates fail visibly when something is wrong. The block renderer catches per-block errors and renders a small red badge in place of the failed block; your page still loads, the bug is obvious, and you can fix the root cause instead of chasing why a fallback string appeared in production.

Field types

The kernel ships 21 canonical field types in internal/cms/field_types/registry.go: string, textarea, richtext (Tiptap with sanitiser), number, range, email, url, date, color, toggle, select, radio, checkbox, image, gallery, file, link, reference, term, object, array. Plus the legacy aliases text (→ string) and repeater (→ array) accepted by the loader for backwards compatibility.

Extensions register additional types through admin_ui.field_types[]. The bundled forms extension contributes vibe-form; media-manager contributes nothing extra (image/gallery/file are kernel types). Get the canonical list at runtime with core.field_types.list.

Per-field configuration

Every field accepts: required (bool), default, placeholder, help (string), and width (full | half | third). The help string is surfaced through MCP so an AI agent can understand what the field is for — write helpful copy. Type-specific keys add to those: select, radio, and checkbox take options[] (plain strings, never {label, value} objects — the React renderer crashes on objects); repeater/array takes sub_fields[]; reference takes node_types[]; term takes taxonomy.

Caching

Set "cache_output": true on a block to opt into render-result caching. The kernel keys the cache by the block-type slug plus a hash of the field values, so two blocks with the same fields share one render. Use this for blocks whose output is a pure function of their fields (a marketing card, a logo cloud); avoid it for blocks that read the current node, the current user, or anything else outside the field map. The block-output cache is invalidated on theme activation and on any update to the block type.

view.html

Block templates are Go html/template templates rendered through the kernel's internal/rendering package, which adds a per-block FuncMap on top of the standard library helpers. Field values are at the root, so .heading reads the heading field directly. Inside a range, the dot rebinds to the iteration value as usual.

blocks/hero/view.html
<section class="hero">
  <h1>{{ .heading }}</h1>
  {{ if .subhead }}<p>{{ .subhead }}</p>{{ end }}
  {{ if .image }}
    <img
      src="{{ image_url .image.src "hero" }}"
      srcset="{{ image_srcset .image.src "hero" "hero-2x" }}"
      alt="{{ .image.alt }}">
  {{ end }}
  {{ if .cta }}
    <a href="{{ .cta.url }}" target="{{ .cta.target }}">{{ .cta.label }}</a>
  {{ end }}
  <ul>
  {{ range .items }}
    <li data-icon="{{ .icon }}">{{ .label }}</li>
  {{ end }}
  </ul>
</section>

Scoped CSS/JS

style.css and script.js next to view.html are auto-injected when the block is used on a page. The kernel deduplicates: a block used five times on a page injects its CSS and JS exactly once. Do not write global selectors — scope every rule to the block's root element (a .hero wrapper class is the convention). The bundled doc-codeblock block's style.css in the squilla theme is a good reference for how to keep block CSS contained.

If you need critical CSS hoisted into <head>, drop it into the layout's head_styles emission instead — block-scoped CSS lands wherever the layout invokes {{ .app.block_styles }}, which is conventionally just before </head> after the theme's bundle.