Getting started

Theme repository layout

A theme is a small git repo. Minimal layout:

<theme-repo>/
├── styles.src.css            # Tailwind source (compiled to styles.css on deploy)
├── img/                      # static assets bundled with the theme (logos, decorative SVGs)
└── templates/
    ├── base.html.twig        # extends '_shared/base.html.twig' from cms-core
    ├── _partials/            # theme-private partials (header, footer, …)
    └── pages/
        ├── default.html.twig # fallback when Page.template doesn't match
        ├── home.html.twig
        ├── treatment.html.twig
        └── …

templates/pages/<name>.html.twig is what Page.template resolves to. A page row stores the template name (e.g. home); the renderer looks for @website/pages/<name>.html.twig and falls back to default.html.twig if absent.

@website is a per-request Twig namespace that points at this theme's templates/ directory on NFS. It's set by cms-core/src/Website/WebsiteTemplateNamespaceConfigurator.php.

How rendering works

visitor request → cms-public PageController
                  → Doctrine: load Page by website + locale + slug
                  → Twig: render @website/pages/<page.template>.html.twig
                  → response cached for 5 min at the CDN

The page and website variables are in scope in every page template — they're Doctrine entities, but the Twig sandbox only exposes the allowlisted accessors (see twig-helpers.md).

How the editor works

The admin SPA at cms-admin.internal opens a page for editing:

admin clicks "Edit" → cms-admin SPA
                      → fetch /api/admin/pages/{id}/render (cms-api, JWT-auth)
                      → cms-api renders the page in editor mode
                          (FieldExtension wraps every field() in
                           <div data-cms-field-name="..."> markers)
                      → SPA stuffs HTML into a srcdoc <iframe>
                      → SPA's editor JS reaches into iframe.contentDocument
                          and attaches inline editing for html/text fields
                          and a floating toolbar (with a Settings button)
                          for media/structured fields

cms-public is not iframed by the editor and never receives editor JS. Theme templates get the same render path in both contexts; only the optional data-cms-field-name markers differ.

Building Tailwind locally

# from the theme repo
~/path-to-cms-public/bin/tailwindcss -i styles.src.css -o styles.css --watch

The bin/tailwindcss standalone binary lives in cms-public/bin/, installed by bin/install-tailwind.sh (Composer post-install hook). Designers don't need to install Tailwind themselves.

Deploying a theme

A theme deploy is fully async:

  1. Push a git tag (e.g. v0.4.2) to your theme repo, on our Gitea or on any other git host the website's theme repository points at (see operators → Theme).
  2. In the admin SPA, open the website's settings → "Deploy theme version".
  3. cms-api dispatches Gsid\CmsCore\Message\DeployTheme over Redis Streams.
  4. cms-public-worker downloads the tagged tarball, extracts to /data/cms/themes/<code>/<version>/, runs bin/tailwindcss against styles.src.css to produce styles.css.
  5. Activation is Website.themeVersion = "<version>" — pointer flip, no copy. Old version dirs stay on NFS for instant rollback.

Version directories are immutable. To re-deploy after fixing a bug, push a new tag.

The Twig sandbox

Every theme template is parsed inside \Twig\Extension\SandboxExtension in sandboxed = true mode. The allowlists are in cms-core/src/Theme/ThemeSandboxPolicy.php:

  • Tags: apply autoescape block do extends for from guard if import include macro set spaceless with. No embed, no use, no verbatim.
  • Filters: standard formatting + escaping (see the file for the full list). No convert_encoding, no inline_css, no markdown_to_html.
  • Functions: see twig-helpers.md. No source(), attribute(), template_from_string(), dump(), constant().
  • Methods: explicit per-class list of get*/is*/has* accessors. Setters and persistence calls are forbidden.
  • Properties: empty — go through getters.

Any tag, filter, function, or method not on the list throws a SecurityError at render time. Adding a core capability requires a cms-core release. Optional modules (cms-core) can extend the allowlist via Gsid\CmsCore\Theme\SandboxContributorInterface: when a website has the catalog module enabled, the catalog contributor adds cms_products, cms_product, cms_categories, cms_featured_products, cms_price, the cms_money filter, and read-only getters on Product / Category / TaxClass — see twig-helpers.md.