Theme development

From theme.json to a live brand: what a theme contains and how it gets into AtlasCMS.

7 min read

What a theme package is

A theme turns a brand's blocks into a website. It is a versioned package with a theme.json manifest, default templates and parts written as block trees, block definitions for the blocks the theme adds, and a few images. The API stores a snapshot of the manifest and the per brand data (which theme runs, its options, template overrides). It never stores or runs theme code.

A package you upload is data only. The API reads the zip, checks every entry and keeps the JSON. It does not execute or serve component code. The components that draw your blocks live in the site that renders the brand, built on the layer packages described in @atlas/nuxt.

A typical package looks like this:

package layouttext
theme.json
blocks/definitions.json
templates/index.json
templates/page.json
parts/header.json
parts/footer.json
screenshot.png

The theme.json manifest

The manifest sits at the root of the package. Every key below is checked on upload and by php artisan themes:sync.

theme.jsonjson
{
  "manifestVersion": 1,
  "slug": "acme",
  "name": "Acme",
  "version": "1.0.0",
  "requires": { "api": ">=0.1.0" },
  "blocks": { "prefix": "acme-" },
  "templates": [
    { "slug": "index", "title": "Fallback" },
    { "slug": "page", "title": "Page" }
  ],
  "customTemplates": [
    { "slug": "landing-no-header", "title": "Landing", "types": ["page"] }
  ],
  "parts": [
    { "slug": "header", "area": "header", "title": "Header" },
    { "slug": "footer", "area": "footer", "title": "Footer" }
  ],
  "menuLocations": [
    { "key": "header", "title": "Main navigation", "maxDepth": 3 }
  ],
  "styleVariations": [],
  "tokens": {},
  "options": {
    "type": "object",
    "properties": {
      "headerCtaLabel": { "type": "string", "maxLength": 40, "x-translatable": true }
    }
  }
}
KeyRule
manifestVersionMust be 1.
slug, versionA lowercase slug of at most 64 characters, and a SemVer version such as 1.2.0.
blocks.prefixRequired. Your block types must start with it. atlas- is reserved.
templatesThe templates the package ships. index is required, it is the final fallback.
customTemplatesTemplates an editor can pick for an entry. Each lists the entry types it applies to. The slug may not be one the template hierarchy already owns.
partsReusable block trees, each with an area such as header or footer.
menuLocationsMenu keys the theme renders. maxDepth is a whole number from 1 to 3.
styleVariations, tokensToken sets. A value may not point at a remote file (//, ://, url(, image-set().
requires.apiA comparator list such as >=0.3.0. A package that needs a newer API is refused.
optionsThe settings a brand may change, declared as JSON schema.

A manifest may not declare scripts, head, routes or server. A package uploaded by a brand may not use extends.

Blocks and block definitions

A block definition says which props a block accepts. It drives the editor form and the server validation of every page that uses the block. A definition is { "type", "version", "schema" }, and the type must start with your blocks.prefix.

blocks/definitions.jsonjson
[
  {
    "type": "acme-hero",
    "version": 1,
    "schema": {
      "type": "object",
      "required": ["heading"],
      "properties": {
        "heading": { "type": "string", "maxLength": 120 },
        "image": { "type": "object" }
      }
    }
  }
]

You can load definitions in three ways:

  • Put them in blocks/definitions.json inside the package. Upload and themes:sync read them.
  • Run php artisan blocks:sync definitions.json --brand=acme on the server. Leave out --brand to save them as global definitions.
  • As an admin, call PUT /v1/manage/block-definitions with { "definitions": [ ... ] } (1 to 200 items). A definition saved this way belongs to the brand.
PUT/v1/manage/block-definitions

Blocks nest three levels deep at most. The two names atlas-template-part and atlas-post-content are renderer boundaries: they need no definition and no write path accepts a definition for them. Until a brand has any definition, block validation is skipped.

Templates and parts

A template is a block tree chosen by the template hierarchy. For a path, the API lists candidates and the first one that exists wins. A page at /tentang tries page-tentang, page, singular, index. A post tries single-{type}-{slug}, single-{type}, single, singular, index. The locale root tries front-page first, the posts page tries home, archive-post, archive, and nothing found uses 404.

For each candidate the API checks, in order:

  1. A published override of this brand for the requested locale.
  2. A published override for the default locale, while the locale_fallback setting is not false.
  3. The file the package ships.

When the package file answers, the page payload says "source": "package" with "blocks": null, and the renderer uses its own copy. A part with no override carries the default blocks from the package.

Editors change a template or part without touching the package. These calls need a publisher:

CallEffect
PUT /v1/manage/theme/templates/{kind}/{slug}/draftSaves { locale?, blocks } as a draft. kind is template or part.
POST /v1/manage/theme/templates/{kind}/{slug}/publishMakes the draft live. 422 when there is no draft.
DELETE /v1/manage/theme/templates/{kind}/{slug}Drops the override so the package default answers again.

A draft is not live output. Publishing it audits the change, raises the brand content version and queues the purge webhook. atlas-post-content renders the entry body, so it is only allowed in a template that renders one entry, and once per tree. A part never holds it.

Theme options and the settings registry

AtlasCMS keeps settings in a registry: each core setting is declared once with a type, a default and the role that may write it. GET /v1/manage/settings/definitions lists them for any member. Theme options are a separate set. Your manifest options object is the only thing that decides which option keys exist, and PUT /v1/manage/settings refuses them.

Options use the same JSON schema subset as block props, plus two markers:

KeyMeaning
x-translatableThe value is a map keyed by brand locale. A site gets the requested locale, then the default locale.
x-privateThe option never reaches a site. Only members see it in the dashboard.
formatOne of uri (checked as a safe link), media (a ready file of the brand), color (#rrggbb) or html (cleaned on save).

A format: media option is stored as a media id and delivered as { media_id, media: { url, mime, width, height, alt, focal } }. A brand may have at most 100 options, and one value may be 8 KB at most. An empty value clears an option back to its default.

Options have a draft and a live copy, so an editor can preview before publishing:

CallRole
GET /v1/manage/theme/options/{slug}Any member
PUT /v1/manage/theme/options/{slug}/draft with { "options": { ... } }Publisher
POST /v1/manage/theme/options/{slug}/publishPublisher
DELETE /v1/manage/theme/options/{slug}/draftPublisher

The public GET /v1/public/theme returns the live options of the active theme. A request with a preview token receives the draft instead, and preview answers are never cached.

Sync a theme on the server

An operator with shell access can store a package directory or a bare manifest as a platform theme every brand can see:

terminalbash
php artisan themes:sync themes/acme
php artisan themes:sync themes/acme/theme.json --definitions=build/definitions.json

The command validates the manifest and one file per template and part. A bad file refuses the whole sync and writes nothing. It also syncs the block definitions. It stores a snapshot and never activates a theme.

Upload a theme

A brand admin can upload a package as a zip. The theme then belongs to that brand only.

POST/v1/manage/themes

The body is multipart/form-data with the file in package and an optional boolean dry_run. Always run a dry run first: it runs the same checks and writes nothing.

terminalbash
curl -s -X POST https://api.atlascms.id/v1/manage/themes \
  -b cookies.txt -H "X-CSRF-TOKEN: $CSRF" -H "X-Brand-Id: $BRAND_ID" \
  -F "[email protected]" -F "dry_run=1"
responsejson
{
  "dry_run": true,
  "result": "would_create",
  "theme": { "slug": "acme", "name": "Acme", "version": "1.0.0" },
  "counts": { "templates": 2, "parts": 2, "blocks": 1, "files": 8 },
  "errors": [],
  "warnings": []
}

result is would_create on a dry run, then created or unchanged on a real upload. unchanged means the same slug and version with the same content is already stored.

The rules of a package:

  • theme.json sits at the root of the zip. A single wrapper folder is not accepted.
  • Only .json, .png, .jpg, .jpeg and .webp files. No hidden files, symlinks or encrypted archives. An image must really be an image.
  • At most 5 MB zipped, 200 files, 2 MB per file and 20 MB unpacked. A brand holds up to 10 themes and 20 versions of each.
  • The slug may not belong to a platform theme, and the blocks.prefix may not collide with a global block.
  • A version is immutable. The same slug and version with different content answers 409 version_exists: raise version in the manifest.

A package with any error answers 422 with the same shape and errors filled, each item { file, path, code, message }. Codes include manifest_missing, file_not_allowed, quota_exceeded, prefix_mismatch and remote_reference. A missing screenshot is only a warning.

An upload raises the content version and audits theme.package.uploaded. It does not switch the brand to the theme.

Upload from CI with a developer token

An admin can mint a developer token for the atlas CLI or a CI job. It carries one scope, theme:write, and reaches nothing but the two theme routes below. It needs no session and no X-Brand-Id, because the token names its brand.

terminalbash
curl -s -X POST https://api.atlascms.id/v1/developer/themes \
  -H "Authorization: Bearer $ATLAS_DEV_TOKEN" \
  -F "[email protected]" -F "dry_run=1"
POST/v1/developer/themes

The answer has the same shape as the dashboard upload, throttled to 10 a minute per token. GET /v1/developer/themes lists the brand's themes. See Security for how tokens are scoped and revoked.

Switch the active theme

Switching is an admin action, in two steps.

POST/v1/manage/theme/active/preflight

Send { "theme": "acme" }. The check writes nothing and answers ready, plus what the brand would lose: menu keys the theme has no location for, entries whose custom template the theme does not declare, and warnings about menu depth, the style variation and stored options the new schema would refuse. Overrides never block a switch: they stay keyed by the theme that made them and return when you switch back.

PUT/v1/manage/theme/active

Send { "theme": "acme" }, or { "theme": null } for no theme. The API pins the synced version, clears the style variation when the slug changes, audits theme.activated and queues the purge webhook with paths: ["*"]. A theme uploaded by one brand can only be activated by that brand.

Preview a theme before it goes live

PUT/v1/manage/theme/active/draft

Send { "theme": "acme" } (admin) to set a preview draft, or { "theme": null } to clear it. Only a request with a preview token renders that theme, so visitors keep seeing the live one. A preview draft writes no purge and moves no content version.

Order of work

Upload with dry_run until it is clean, upload for real, set the preview draft and walk the site with a preview token, then run the preflight and activate.

Publish means content

A theme package is uploaded, never published. Publish is for entries, template and part drafts and theme option drafts.

Was this page helpful?

Suggest a change