A theme is a Shopify Online Store 2.0 theme: the same folders, the same Liquid, the same section and block schemas, the same JSON templates. Soundslot renders it with LiquidJS. Where a commerce idea has no rental equivalent the difference is named below; everything else works as Shopify documents it.
_dev/ is the reference theme. It is hidden from the gallery and exists to be
copied — theme blocks, a static block, plurals, a second language and every page
template are demonstrated there, and a test renders it on every CI run, so it
cannot drift from the engine.
Directory layout
layout/theme.liquid the document
layout/<name>.liquid an alternate layout a template can name
sections/<name>.liquid a section: markup + {% schema %}
sections/<name>.json a section group (header, footer, aside, custom.*)
sections/main-<page>.liquid a page's main section (see Templates)
blocks/<name>.liquid a theme block: markup + {% schema %}
templates/<page>.json one per page — index, room, rooms, classes, …
templates/<page>.<suffix>.json an alternate a studio picks per room or page
snippets/<name>.liquid {% render 'name' %}
locales/<code>.default.json the storefront copy in the default language
locales/<code>.json the same copy in another language
locales/<code>.default.schema.json the editor's labels, for t: keys in schemas
assets/** css, js, images, fonts
config/settings_schema.json the theme's own settings
config/settings_data.json the theme's saved values, and its theme styles
Custom themes upload as a zip with exactly these relative paths.
Output is not escaped
As on Shopify, {{ x }} prints x as it is. The studio's own content — room
names, settings, pages — renders as written, so escape where markup would
break: alt="{{ room.name | escape }}".
Two kinds of value are made safe before a theme sees them:
- Anything a customer typed — a review's name, title and body — is
HTML-escaped when the context is built. Print it as it is;
| escapeon top would escape it twice. - Richtext settings and custom fields are reduced to
p br strong em b i ul ol li a[href]when saved and again when rendered.
| raw and | richtext still exist for themes written for the earlier engine;
neither is needed.
The layout
<!doctype html>
<html lang="{{ request.locale.iso_code }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page_title }}</title>
<meta name="description" content="{{ page_description | escape }}">
<link rel="canonical" href="{{ canonical_url }}">
{{ content_for_header }}
{{ 'theme.css' | asset_url | stylesheet_tag }}
</head>
<body>
{% sections 'header-group' %}
<main>{{ content_for_layout }}</main>
{% sections 'footer-group' %}
</body>
</html>
content_for_header is scripts and styles: the platform's --sf-* properties,
the colour scheme rules, the Google Fonts links, the structured data, the
studio's analytics, the robots and verification meta, and the reveal utility.
The layout writes the title, description, canonical and social card itself; any
of the four it leaves out the engine adds before </head>, so a ported layout
never has two of each. The hosted themes keep the social card in
snippets/meta-tags.liquid.
content_for_layout is the page. On a storefront page it is the template's
sections; on a platform-owned page (sign in, account, checkout) it is our own
markup, rendered inside your chrome.
A template may name an alternate layout with "layout": "<name>", or render
with none at all with "layout": false.
Sections
A section is markup plus a schema. The engine wraps what you write the way Shopify does, with our prefix:
<div id="ss-section-<id>" class="ss-section <class>"
data-section-id="<id>" data-section-type="<type>">…</div>
tag picks the element — div (default), section, header, footer,
article or aside. A section in a group also carries
ss-section-group-<file>, and one that names a colour scheme carries
color-scheme color-scheme-<id>.
<h1>{{ section.settings.heading }}</h1>
{% for block in section.blocks %}
<a {{ block.shopify_attributes }} href="{{ block.settings.url }}">{{ block.settings.label }}</a>
{% endfor %}
{% schema %}
{
"name": "Hero",
"tag": "section",
"class": "hero",
"limit": 1,
"settings": [
{ "type": "text", "id": "heading", "label": "Heading", "default": "Rehearse here" }
],
"blocks": [
{ "type": "link", "name": "Link", "settings": [
{ "type": "text", "id": "label", "label": "Label" },
{ "type": "url", "id": "url", "label": "URL" }
]}
],
"max_blocks": 6,
"presets": [{ "name": "Hero", "category": "Banners" }],
"enabled_on": { "templates": ["index"] }
}
{% endschema %}
Inside a section: section.id, section.type, section.settings.<id>,
section.blocks (statics and hidden blocks left out), section.index and
section.index0 (its place in the template or group), section.location
(template, the group's key, or static) and section.name (the studio's own
name for it). Each block is { id, type, settings, shopify_attributes, blocks, static, name }.
The schema takes Shopify's keys: name, tag, class, limit, settings,
blocks, max_blocks (50 when absent), presets (with category and nested
blocks), enabled_on and disabled_on (template types and group types,
"*" for all, custom.* for every custom group), default (a static
section's starting state) and locales.
{% section 'name' %} renders a static section: its saved state when the
studio has one, else its schema's default.
A section's own CSS and JavaScript
{% stylesheet %}.hero { display: grid }{% endstylesheet %}
{% javascript %}document.querySelectorAll('.hero').forEach(setUp){% endjavascript %}
Both are raw and belong to the file, not the page: each is written once before
</body>, however many times the section or block renders.
{% style %}…{% endstyle %} is a <style> element written where it stands,
with Liquid inside it.
{% style %}#ss-section-{{ section.id }} { --gap: {{ section.settings.gap }}px }{% endstyle %}
A section's custom_css in a template is scoped to that section's wrapper.
One broken section
A section or block that throws while rendering draws nothing on the live
storefront and the rest of the page renders. In the editor's preview it draws
Liquid error (sections/<file>): <message> in its place. Every failure is
logged and filed on the platform's Operations screen.
Theme blocks
A file in blocks/ is a block any section can hold. Its schema has name,
settings, blocks, presets, tag (null renders no wrapper) and class.
<div class="text">{{ block.settings.text }}</div>
{% schema %}
{ "name": "Text",
"settings": [{ "type": "richtext", "id": "text", "label": "Text" }],
"presets": [{ "name": "Text" }] }
{% endschema %}
A section accepts theme blocks with { "type": "@theme" } (any) or
{ "type": "text" } (that one), and renders them where it says:
<div class="group">{% content_for 'blocks' %}</div>
Each renders in <div id="ss-block-<id>" class="ss-block <class>" …> unless
its schema says "tag": null. A block with blocks of its own renders them the
same way, eight levels deep. A static block renders by id, with any further
arguments handed to it as variables:
{% content_for 'block', type: 'heading', id: 'title', size: 'xl' %}
Its settings live in the template under the parent's blocks with
"static": true, and it never appears in block_order or section.blocks.
@app blocks are accepted and render nothing.
Section groups and templates
Both are { "sections": {…}, "order": […] }. A group also declares its type —
header, footer, aside or custom.<name> — and is rendered by file name:
{% sections 'sidebar-group' %}.
{
"layout": "theme",
"wrapper": "div#page.page-width",
"sections": {
"hero": { "type": "hero", "settings": { "heading": "Rehearse here" } },
"detail": {
"type": "room-detail",
"name": "Room facts",
"custom_css": ["h2 { letter-spacing: .02em }"],
"blocks": { "gear": { "type": "gear", "settings": { "heading": "Gear" } } },
"block_order": ["gear"],
"disabled": false
}
},
"order": ["hero", "detail"]
}
order decides what renders and in what sequence; a disabled: true section or
block is skipped. A studio editing the theme saves an override that replaces the
whole template or group, so keep the file the sensible starting state.
Every page is a template
| template | the page | main section |
|---|---|---|
index |
the home page | — |
room |
one room (Shopify's product) | — |
rooms |
the room list (Shopify's collection list) | main-rooms |
classes |
classes | main-classes |
rentals |
rentals | main-rentals |
people |
the people a room can be booked with | main-people |
gift-cards |
gift cards | main-gift-cards |
hour-packs |
hour packs | main-hour-packs |
memberships |
membership plans | main-memberships |
account |
a customer's own pages | main-account |
auth |
sign in, claim, reset, verify | main-auth |
page |
a page the studio wrote | main-page |
policy |
privacy and terms | main-policy |
404 |
nothing at this URL | main-404 |
password |
the lock screen | main-password |
Checkout has no template. It renders in the layout alone, with every theme script removed and the footer stripped.
index and room are sections all the way down. Every other page has a
platform-owned island in the middle of it — Soundslot's equivalent of an app
block in a main section — and the main section is where it lands:
{% if section.settings.heading %}<h1>{{ section.settings.heading }}</h1>{% endif %}
{{ content_for_main }}
{% schema %}
{ "name": "Account", "class": "sf-main", "limit": 1,
"settings": [{ "type": "text", "id": "heading", "label": "Heading" }],
"enabled_on": { "templates": ["account"] } }
{% endschema %}
Inside a section {{ content_for_layout }} is the same island, so a main
section written either way works. The engine guarantees exactly one main section
per page: a second is skipped, and a template with none gets the built-in one.
A theme that ships no templates/account.json still has an account page, from
the platform's own template.
Alternate templates
templates/room.gallery.json and templates/page.wide.json are alternates. A
studio picks one per room or per page, and the engine renders it when the theme
ships that suffix. {{ template.name }} and {{ template.suffix }} say which.
Settings
config/settings_schema.json is an array whose first entry is the theme's
identity:
[
{
"name": "theme_info",
"theme_name": "Marquee",
"theme_version": "1.0.0",
"theme_author": "Soundslot",
"theme_documentation_url": "https://example.com/docs",
"theme_support_url": "https://example.com/support",
"description": "Poster type on a violet ground.",
"mode": "dark",
"thumbnail": "thumbnail.png",
"hidden": false
},
{ "name": "Colors", "settings": [ … ] }
]
mode is the theme's ground — it decides the booking pages' palette, never the
visitor's OS setting. hidden keeps a theme out of the gallery.
config/settings_data.json is Shopify's: current is the values, or the name
of one of presets, the theme styles a studio picks from. The studio's saved
settings win over both, and a field's default sits under all of them.
{ "current": "Night",
"presets": { "Day": { "accent": "#e8a03d" }, "Night": { "accent": "#7c5cff" } } }
Field types
Every field is { "type", "id", "label", "default"?, "info"?, "visible_if"? }.
visible_if is Shopify's — "{{ section.settings.show_count }}", or a setting
compared to a literal with == / != — and the editor hides the field while it
is false. A type the editor has no control for stays editable as a text box.
| type | value in Liquid |
|---|---|
text, textarea |
the string |
richtext, inline_richtext |
sanitized HTML |
html, liquid |
the studio's HTML, as written |
number, range (min, max, step, unit) |
the number |
checkbox |
true / false |
select, radio (options) |
the chosen value |
text_alignment |
left, center or right |
color |
a colour: prints as hex; .red, .green, .blue, .alpha, .hue, .saturation, .lightness, .rgb |
color_background |
the gradient, as written |
color_scheme |
the scheme: prints as its id; .settings.<id> are colours |
color_scheme_group |
settings.color_schemes, every scheme in order |
font_picker |
a font: prints as the family; .family, .weight, .style, .variants |
image_picker |
{ src, width, height, alt } — .src works for a value saved as a bare URL |
video |
the file, as image_picker reads |
video_url |
the URL; .type is youtube or vimeo, .id the video |
link_list |
the menu: .links, and as a key it is the handle, so linklists[section.settings.menu] works |
url |
the link |
page |
the studio's page, from pages |
room, room_list |
rooms, from rooms |
room_type, room_type_list |
room types, from tiers |
location, location_list |
buildings, from locations |
header, paragraph |
sidebar text, no value |
Shopify's commerce pickers map onto the rental resource each stands for, so a theme written for Shopify needs no change:
| Shopify | Soundslot |
|---|---|
product |
room |
product_list |
room_list |
collection |
room_type |
collection_list |
room_type_list |
blog, article, metaobject |
none yet — a text box |
Our older names are accepted: toggle → checkbox, font → font_picker,
image → image_picker, key → id, help → info.
Colour schemes
A color_scheme_group declares what every scheme carries, as any list of
color and color_background settings:
{ "type": "color_scheme_group", "id": "color_schemes", "label": "Colour schemes",
"definition": [
{ "type": "color", "id": "background", "label": "Background", "default": "#ffffff" },
{ "type": "color", "id": "text", "label": "Text", "default": "#17140f" },
{ "type": "color", "id": "button", "label": "Button", "default": "#e8a03d" }
] }
settings_data.json carries the schemes under color_schemes, keyed by any id
(scheme-1, background-1, inverse). The first is the default and cannot be
removed; nor can one a section still names. Loop them to write your own CSS:
{% style %}
{% for scheme in settings.color_schemes %}
.color-{{ scheme.id }} { --color-background: {{ scheme.settings.background.rgb }}; }
{% endfor %}
{% endstyle %}
The platform's tokens
Sign-in, account and checkout are styled from --sf-bg, --sf-surface,
--sf-ink, --sf-muted, --sf-accent, --sf-radius, --sf-font-display and
--sf-font-body. None of them is a setting you must declare. The engine reads,
in order:
- the settings
bg_color,surface_color,ink_color,muted_color,accent_color,radius(sharp/soft/round),display_fontandbody_font, when the theme has them; - otherwise the first colour scheme (
background,textorforeground,accent,buttonorprimary, …) and the theme's font settings (type_header_font,type_body_font, …); - otherwise neutral defaults.
A colour that is not a hex is never written into CSS, and a font family not on
the server's Google Fonts allowlist falls back to the default. custom_css is
the studio's own stylesheet, appended after everything else.
The studio's own accounts
A theme declares one url setting per network and the engine reads them into
studio.socials[]. Only http(s) values reach an href.
| setting | label |
|---|---|
social_instagram |
|
social_facebook |
|
social_youtube |
YouTube |
social_tiktok |
TikTok |
social_x |
X |
social_bandcamp |
Bandcamp |
Languages
Every locales/<code>.json is a language the theme speaks, and the one named
<code>.default.json is the default. The storefront renders in the language
the studio chose, else the default, and the studio's own rewording of any string
sits over the file.
{ "rooms": { "count": { "one": "{{ count }} room", "other": "{{ count }} rooms" } } }
{{ 'rooms.count' | t: count: rooms.size }}
{{ 'general.greeting' | t: name: shop.name }}
count: picks the CLDR plural form for the language (zero wins at 0 when the
theme wrote one). A key nothing declares renders Translation missing: <key>.
{{ review.createdAt | date: format: 'month_day_year' }} reads date_formats.* from the
locale before Shopify's own named formats.
Schema labels may be t: keys. They are read from locales/*.schema.json, with
a schema's own locales over them.
Liquid objects
Shopify's, under Shopify's names:
shop— the studio:name,url,email,currency,locale, plus everything instudioroutes—root_url,collections_url(the rooms),cart_url(the booker),account_url,account_login_url, plus everything inurlsrequest—page_type,path,locale.iso_code,design_mode(true in the editor's preview)template—name,suffix,directorysettings— the theme's global settingspage_title,page_description,canonical_url,page_imagelinklists— the studio's menus by handle:linklists['main-menu'].links, each link{ title, url, kind, active, child_active, links[] }pages[]— the studio's own pages (title,handle,url), alsopages['about']localization.language.iso_codesection,block,form,paginatewhere Shopify has them
Soundslot's own:
studio—name,slug,logoUrl,address(line1,line2,city,region,postalCode,country,full),phone,contactEmail,hoursByDay[](key,label,opens,closes,hours,closed),hoursToday,openNow,closesAt,opensNext,policies.cancellation(a finished sentence),policies.refundMode,prices_include_tax,tax_labelandsocials[]rooms[]—id,name,code,tier,description,photos[],sqft,dimensions,fits,hourlyRates[](activity,cents,peakCents),gear[],amenities[],approvalRequired,location,url,bookable,ctaUrl,ctaKey,rateKey,rating,payment,nextFreeLabel,fields.<key>andcustom_fields[]tiers[]— room types:key,label,blurb,rooms[]locations[]— every building, primary first, each with its owntimezone,hoursByDay,fieldsandcustom_fieldsreviews[]andrating— the studio's published reviewsurls—home,rooms,book,sessions,signIn,reveal_scriptseo—title,description,canonical,image,json_ld,headpage—template,title,url; on thepagetemplate alsohandle,contentandpublished_atpassword_message,currency,basePath
On templates/room.json: room, the same shape as an entry in rooms.
Tags
Shopify's: section, sections, content_for, render (with for and
with), form, paginate, style, stylesheet, javascript, schema,
doc, liquid, echo, cycle, increment, decrement, capture,
tablerow, raw and the control-flow tags. layout tags in a Liquid file are
not supported; a JSON template names its layout.
Forms
| form | does |
|---|---|
{% form 'customer' %} (or 'newsletter') |
puts contact[email] on the studio's marketing list |
{% form 'customer_login' %} |
hands customer[email] to sign-in, which sends the code |
Inside the form, form.posted_successfully? and form.errors answer the last
post. A form type with no handler renders nothing.
Pagination
{% paginate rooms by 6 %}
{% for room in rooms %}…{% endfor %}
{{ paginate | default_pagination }}
{% endpaginate %}
It pages a list the context holds, by ?page=.
Filters
- money —
money,money_with_currency,money_without_currency,money_without_trailing_zeros, all in the studio's currency, from cents - assets —
asset_url,asset_img_url,file_url,inline_asset_content,stylesheet_tag,script_tag,preload_tag - images —
image_urlandimage_tagat the width you ask for, andplaceholder_svg_tag - colour —
color_to_rgb,color_to_hsl,color_to_hex,color_extract,color_brightness,color_contrast,color_lighten,color_darken,color_saturate,color_desaturate,color_modify,color_mix,color_difference,brightness_difference - fonts —
font_url,font_face,font_modify, all on the allowlist - text —
t,date(strftime or Shopify's named formats, in the studio's zone),time_tag,json(safe inside a<script>),handleize,pluralize,highlight,link_to,within,url_escape,url_param_escape,default_pagination, and LiquidJS's standard filters
Booking
Booking and checkout are platform-owned; a theme never builds a booking form. A
room's call to action is ctaUrl with ctaKey wording it:
<a href="{{ room.ctaUrl }}">{{ room.ctaKey | t }}</a>
ctaUrl is the hosted checkout where room.bookable, and the building's own
mailto:/tel: otherwise. room.rateKey is set where there is no hourly price
to print:
{% if room.rateKey %}{{ room.rateKey | t }}{% else %}{{ room.hourlyRates.first.cents | money }}{% endif %}
There is no widget and no iframe. Checkout renders inside your layout with every theme script removed, so nothing you ship runs where somebody types a card number.
The password lock
A studio can close its storefront while it builds. Pages then render the
password template at HTTP 200 with noindex; a themed URL answers 307 to
/password. password_message is the studio's own line; the form itself is the
platform's island, so main-password writes content_for_main. Staff previewing
a theme are never locked out of their own site.
The studio's own code
A studio can paste a tag manager id, a Crisp website id and raw HTML for the
head and the body. The first two are loaded by sf-tracking.js, after consent
where the studio asked for it; the raw HTML runs on every storefront page with
the page's nonce. None of it reaches checkout, and a theme writes no embed
markup of its own.
Uploading
An upload is refused only when the theme cannot render:
- no
layout/theme.liquid - a
.liquidfile that does not parse - a JSON file (or a section's
{% schema %}) that does not parse - a path outside the folders above, or a file over its limit
Everything else is stored, and comes back as findings named after the Theme Check rule they match:
| rule | finds |
|---|---|
RequiredLayoutThemeObject |
a layout that never outputs content_for_header or content_for_layout |
MissingTemplate |
no index or room template; a section, group, snippet or block that is not there |
MissingContentForMain |
a main section that never outputs content_for_main |
ValidSchema |
a schema with no name, a setting type we do not know, a scheme default nothing declares |
UniqueSettingId |
a setting id declared twice |
ValidVisibleIf |
a visible_if that is not a {{ }} condition |
ValidBlockTarget |
a block the section neither declares nor accepts |
ValidSchemaName |
a schema name over 25 characters |
TranslationKeyExists |
a t key the default locale does not have |
MissingAsset |
an asset_url naming a file that is not in assets/ |
ParserBlockingScript |
a <script src> without defer or async, or script_tag |
ImgWidthAndHeight |
an <img> without width and height |
ValidJSON |
a locale that is not nested strings |
Limits are Shopify's: a 50MB zip, 100,000 files, 256KB per Liquid file, 512KB
per template or group, 1.5MB for settings_data.json and each locale, 20MB per
asset, 25 sections per template, 50 blocks per section and 50KB per text,
richtext, HTML or Liquid setting. No theme is ever refused for weight.
The themes we host
Every hosted theme ships all fifteen templates, uploads with no finding at all, passes axe with no moderate or worse violation on six pages, and is held nightly to the Theme Store's Lighthouse bars: 60 for performance and 90 for accessibility, mobile, on its home and rooms pages.
CLI
@soundslot/cli is the same work without the browser. It is a client of the
public API — the one @soundslot/sdk-public is generated from — and it renders
nothing itself: a preview URL always points at the server.
npx @soundslot/cli theme init my-theme
soundslot login [--key <key>] [--url <url>]
soundslot theme init [dir] [--theme <slug>]
start from a theme we ship; no key needed
soundslot theme check [dir] run the upload's checks offline, findings by Theme Check rule
--fail-level error|warning|suggestion (default error)
soundslot theme push [dir] upload it as an unpublished theme and preview it
soundslot theme pull [dir] unpack the live theme, or --theme <ref>
soundslot theme dev [dir] watch, push to a development theme, open the preview
theme init downloads the reference theme from GET /api/v1/themes/{slug}/archive.
That endpoint takes no credential, so this is the one command that works before
you have an account. --theme <slug> starts from one of the other themes the
product ships.
login takes an API key issued under Developers in the console. It needs
storefront.manage. The key is stored in ~/.soundslot/config.json,
owner-only, and is never printed; SOUNDSLOT_API_KEY overrides it and
SOUNDSLOT_API_URL overrides the API the CLI talks to.
theme check runs offline, with the same checks the server runs. A refusal
prints under the upload's own code (invalid_theme, liquid_parse_error) and
exits 1; every finding prints with its severity and rule name, and exits 1 when
one is at or above --fail-level.
theme push zips the theme folders, uploads them, prints the upload's findings
and then a preview link to the theme on your storefront.
An upload writes an unpublished theme. Pushing over the theme that is serving
the storefront is refused with theme_is_live; --allow-live is the only way
to write it, and it takes effect on the live site immediately.
theme pull unpacks the live theme, or the one --theme names.
theme dev pushes on save, debounced, and opens that preview once. Every save
goes to <theme_name> (development), a theme of its own, so it never writes a
published theme.