Every editor-manageable node ends with a {% schema %} block containing a
JSON document. The engine parses it into a NodeSchema
(crates/ringroad-renderer/src/section/mod.rs) and registers it in the
theme manifest under the node’s type name. The editor reads these schemas
to build the “Add section” panel, the settings forms, and the child-
placement rules.
{% schema %}
{
"name": "Hero",
"tag": "section",
"class": "rr-hero-wrapper",
"limit": 1,
"settings": [ … ],
"slots": { … },
"presets": [ … ]
}
{% endschema %}Node schema fields
| Field | Type | Default | Meaning |
|---|---|---|---|
name |
string | , | Human-readable node name (required). |
role |
"section" | "simple" |
"section" |
Editor placement. Prefer the @editor: marker over this field; both work. |
tag |
string | "div" |
Wrapper element: section, div, header, footer, article, aside. |
class |
string | , | Extra class appended to the wrapper (rr-node rr-node--{type} {class}). |
limit |
integer | , | Max instances per page/group (e.g. 1 for hero). |
settings |
array | [] |
The node’s settings definitions (see below). |
accepts |
array | [] |
Allowed child types. Entries are type names, "@theme" (any theme node), or "@app" (app-injected nodes, stub today). |
max_children |
integer | 50 |
Max children in the implicit default slot. |
slots |
object | , | Named slots instead of the implicit default slot; each maps to { "accepts": [...], "max": n }. |
presets |
array | [] |
Pre-built configurations shown in the “Add section” panel. |
default |
object | , | The preset used when the node is instantiated (optional). |
locales |
object | {} |
Per-locale label overrides. |
enabled_on / disabled_on |
array | , | Page-type allow/deny lists for the editor. |
accepts, child type specs
"accepts": ["slide", "@theme", "@app"]"slide", exactly the node typeslide."@theme", any node registered in the theme (expanded at load time to every type not already listed)."@app", app-injected nodes. Stub today:expand_app_in_acceptsremoves the marker without injecting anything (section/registry.rs).
slots, named child buckets
When a node declares slots, children are placed into named buckets
instead of the single default slot. Each child instance carries a slot
field; the template reads pre-rendered slot HTML from node.slots.{name}.
The default theme’s hero node uses two slots:
"slots": {
"col": { "accepts": ["@theme"] },
"cta": { "accepts": ["button"], "max": 3 }
}{{ node.slots.col }}
{% if node.slots.cta %}<div class="rr-hero__actions">{{ node.slots.cta }}</div>{% endif %}presets, “Add section” starting points
Each preset pre-fills the node (and its children) when a merchant adds it:
"presets": [
{
"name": "Default",
"type": "hero",
"settings": {},
"children": [
{
"type": "button",
"slot": "cta",
"settings": { "label": "Shop now", "url": "/products", "style": "primary" }
}
]
}
]children accepts the old "blocks" key as an alias.
Setting types
Settings are deserialized from the type field via
#[serde(tag = "type", rename_all = "snake_case")] into one of 36
SettingDefinition variants (src/schema/types.rs). Every definition has
id (the machine key, used in node.settings.{id}) and label; most have
info (help text) and type-specific default.
Basic inputs
type |
Config fields | Default rule |
|---|---|---|
checkbox |
default: bool? |
false |
number |
default: f64?, placeholder |
null |
radio |
default: string?, options: [{value,label}] |
first option |
range |
default (required), min, max, step?, unit? |
the default |
select |
default: string?, options, group? |
first option |
size |
default: string?, unit?, placeholder |
null |
text |
default: string?, placeholder |
null |
textarea |
default: string?, placeholder |
null |
{ "type": "select", "id": "height", "label": "Height",
"options": [ { "value": "auto", "label": "Auto" }, { "value": "large", "label": "Large" } ],
"default": "large" }size is a free-form CSS length — any unit (200px, 100%, 10rem,
60vw), the keywords auto / none / initial / inherit / unset,
unitless 0, or a CSS function with balanced parens (calc(100% - 2rem),
clamp(1rem, 5vw, 3rem), var(--gap)). Values are validated at schema
load, save, and render time, so templates can emit node.settings.{id}
verbatim into style="..." without appending a unit:
{% if node.settings.width %}width: {{ node.settings.width }};{% endif %}The optional unit (default "px") is what the editor preselects, and
what legacy bare numbers (stored by the old px range settings, e.g.
500) are re-emitted as during render-time merging. An empty default
means “unset” and emits nothing.
Resource pickers
type |
Config fields |
|---|---|
product, collection, page, article, blog |
id, label, info? |
product_list, collection_list, article_list |
id, label, limit?, info? |
Resource pickers have no default, the merchant must pick.
Color
type |
Config fields | Where |
|---|---|---|
color |
default: string?, placeholder |
node settings |
color_background |
default: string? |
node settings (solid or gradient) |
color_palette |
default: { key: hex }, 2–20 colors |
theme-level only |
color_scheme |
default: string? |
section-level scheme picker |
color_scheme_group |
definition: ColorSchemeField[], default_scheme? |
theme-level only |
color_palette + color_scheme_group together drive the color cascade,
see The color cascade.
Text & media
type |
Config fields | Notes |
|---|---|---|
font_picker |
default (required, e.g. "inter_n4") |
|
html |
default?, placeholder |
Raw HTML |
image_picker |
id, label |
Returns a URL |
inline_richtext / richtext |
default? |
Rich text |
link_list |
default? |
Menu handle picker |
code |
language (required), default? |
Max 50 KB |
metaobject / metaobject_list |
metaobject_type (required), limit? |
|
text_alignment |
default? |
left / center / right / justify |
url |
id, label |
|
video |
id, label |
Media-library picker |
video_url |
accept?: ["youtube","vimeo",…] |
URL input |
Layout / sidebar (no value output)
type |
Config fields |
|---|---|
header |
content (heading text) |
paragraph |
content (help text) |
group |
label, info?, settings: SettingDefinition[] |
These render as structural UI in the settings panel and produce no setting value.
Structural groups
{
"type": "group",
"label": "Content",
"settings": [
{ "type": "text", "id": "heading", "label": "Heading" },
{ "type": "textarea", "id": "subtext", "label": "Subtext" }
]
}group is a collapsible titled section that organizes other settings in
the editor’s settings panel. It is structural only: groups contribute no
key to the settings map, members are flattened depth-first (document
order) before defaults are applied or values validated, and groups nest
recursively (a group’s settings may contain further groups). The editor
renders each group as a titled row with a collapse chevron; members appear
beneath it. Usable in both theme.json global settings and node
{% schema %} blocks (see themes/default/nodes/hero.jinja for a real
example).
Validation and defaults
At theme load, every setting passes SettingDefinition::validate():
range:min < max;defaultwithin[min, max].select/radio: at least one option;default, if set, must be an option value.color_palette: 2–20 colors; keys must start with a letter.font_picker:defaultnon-empty.metaobject/metaobject_list:metaobject_typenon-empty.
At render time, apply_setting_defaults merges the merchant’s stored
values with schema defaults: missing keys are inserted, and stored values
that fail type validation are replaced by the default (invalid checkbox
values fall back to false, invalid numbers to the default, lists capped
at 50 entries, code capped at 50 KB). Because of this, node templates can
reference node.settings.{id} without | default() guards, the default
is always present.