Theme authoring — instructions for Claude
You are working on a GSID CMS theme: a Twig template tree + a
Tailwind v4 CSS source file, packaged as a git tag and deployed by
the cms-public-worker pod. Examples are the other theme
repositories under ceems/ on Gitea (svbeautique, pmumelanie,
purefragrance, kringlooptilburgnoord, ertveldetruckparts, gsid, …).
Read this file first when the user asks you to do anything in a theme repo.
Always-read references
These are the other pages of this handbook (the ceems/doc
repository). Read what's relevant to the task, and don't summarise
from training data: the docs are the source of truth.
| When the task involves… | Read |
|---|---|
| Repo layout, sandbox boundary, deploys | getting-started.md |
| Calling any Twig helper | twig-helpers.md |
Editable content blocks (field('name', 'type')) |
field-types.md |
| Contact / quote / newsletter forms | forms.md |
| Carousel / lightbox / slideshow JS+CSS | per-page-modules.md |
| Playing an uploaded video (inline or header background) | twig-helpers.md → cms_video() |
styles.src.css, @source, the deploy compile |
tailwind-and-styles.md |
| Backend modules (catalog/customer/cart/checkout) helpers | twig-helpers.md → module helpers + operators/modules/ |
Landing-page templates under templates/landing/ |
operators/modules/landingpages.md |
An FAQ accordion, or the /faq page templates |
twig-helpers.md → FAQ module + operators/modules/faq.md |
| Patterns to avoid | do-and-dont.md |
If the task spans more than one of these, read each before writing.
Hard rules — do not break
-
Stay inside the Twig sandbox. Tags, filters, functions, and entity methods are allowlisted in
cms-core/src/Theme/ThemeSandboxPolicy.php. Reach for something that isn't allowlisted → SecurityError at render time. The docs list what's available; if the user wants something new, stop and tell them it requires a cms-core release, don't try to work around it. -
No PHP files in a theme. Themes are template + CSS only. If you find yourself wanting to write a service, a listener, an entity — that's a backend change, not a theme change.
-
No
npm, nopackage.json, nonode_modules/, no bundler config. Tailwind ships as a single binary (cms-public/bin/tailwindcss) used at deploy time. Don't add a JS toolchain. -
No
importof third-party JS libraries in theme JS. A theme that shipsapp.jsmay use plain ES module syntax + browser APIs only. If a library is needed it goes incms-public/public/assets/as a per-page frontend module — that's a backend change too. -
Never edit
cms-core/,cms-public/,cms-api/, orcms-admin/from a theme task. Those are separate repos with their own release cycles. If a theme-side need points at one of those, surface it to the user — don't sneak a fix in. -
Never commit the compiled
styles.css. The deploy worker produces it. Theme repos are source-only. Addstyles.cssto.gitignoreif it isn't already. -
Never edit
_shared/partials by copying them into the theme. If_shared/_partials/seo.html.twigdoesn't fit, mirror the path in the theme (templates/_partials/seo.html.twig) — the@websitenamespace wins. Don't ship a fork of_shared/base.html.twig. The<html>tag lives there and is not overridable by design: it already carrieslanganddirfor the rendered locale, so an RTL site needs no fork and nodirectionhack. See multi-locale.md → Right-to-left locales. -
No inline
on*=handlers, ever. Noonclick=,onchange=,onsubmit=. A nonce can be put on a<script>tag and never on an attribute, so a single inline handler is what keeps'unsafe-inline'alive in the site's script policy. cms-core's shared base carries delegated listeners for the common cases:<select data-submit-on-change> submits its own form <select data-go-on-change> navigates to the chosen value <form data-confirm="Sure?"> asks before it posts <button data-toggle="#panel"> toggles `hidden` on the target (and maintains aria-expanded)Anything else goes in the theme's own
app.jsas anaddEventListener. A<script>tag you write in a template is nonced automatically; one you build out of a variable is not, and will be blocked once the policy tightens. See operators/security.md → Content-Security-Policy. -
Migrations are not your concern. A theme task should never touch
migrations/anywhere. If a feature needs a schema change, that's a cms-core release.
Sandbox-error checklist
When a render fails with Twig\Sandbox\SecurityError, walk this list
before assuming the framework is broken:
- Are you calling a function not in twig-helpers.md? Almost certainly the cause. Find the equivalent allowed helper or escalate to a cms-core change.
- Did you call
something.fooon an entity? Property access only works for arrays + stdClass. Use the getter (something.getFoo()orsomething.fooif the getter exists with that exact name). - Did you call a filter that isn't in
ThemeSandboxPolicy::FILTERS?inline_css,markdown_to_html,convert_encodingare common ones that aren't there. - Did you use a tag that isn't in
ThemeSandboxPolicy::TAGS?embed,use,verbatimare blocked. - Did the user toggle a backend module on but you're missing a helper? Check that the contributor for that module is registered for the active website (the operator-facing operators/modules/ covers each).
When in doubt, read ThemeSandboxPolicy.php directly — it's in
cms-core/src/Theme/.
Editor-safety patterns
The inline editor in cms-admin renders the theme via cms-api with
data-cms-field-name markers, and it shows only the page's
{% block body %}, not the whole page; the designer preview does the
same. Both render the page template whole, as the public site does, and
take the body block out of it, so a top-level {% import %} works there
too. Four rules that catch everyone at least once:
-
Use
field()for everything editable. Reaching past it to readpage.getFields()['x'].content|rawworks on the public site but makes the field invisible to the editor. If you need to control the outer tag, use type'text'— it renders content with no wrapper but still emits editor markers in editor mode. See field-types.md →text. -
Don't gate field wrappers behind
{% if x is defined %}only. The editor needs the empty wrapper to be hoverable. Widen withor cms_is_editing():{% if banner.src is defined or cms_is_editing() %} <div class="banner-wrapper">{{ field('banner', 'image') }}</div> {% endif %} -
Don't put
<script>tags inside fields. The editor's iframe strips behavioural JS. If a field needs JS to look right in public mode, that's a per-page frontend module (per-page-modules.md), not inline<script>. -
Import macros in every template that calls them, partials included. An
{% import %}alias is local to the template that imports it: it does not reach an{% include %}d partial. On the public sitestrict_variablesis off, so a partial calling an alias it did not import does not fail; every label it prints comes out as an empty string, with no error and no log line. Import in the partial itself:{# _partials/footer.html.twig #} {% import '@website/_macros.html.twig' as site %} ...(A top-level import in a page template is fine in the editor and the designer preview: both render the template whole. They used to render the body block alone, where a top-level import never ran and its alias was a hard 500.)
For site-wide data (phone, address, socials) don't use macros at all: that is
cms_company()(twig-helpers.md).
When the user says "make a new page"
Two parts. Both are needed:
-
Template at
templates/pages/<name>.html.twig. Extends the theme'sbase.html.twig, defines abodyblock, callsfield()for every editable block. Follow the existing theme's conventions — section structure, container widths, font usage, card patterns. If the theme hasdefault.html.twig, that's the safe starting point to copy from. -
A row in the admin — but the operator does this in the SPA; you don't create database rows from a theme task. Tell the user to: open Pages → New, fill slug + title + locale, set
template = '<name>', save, edit the fields. Don't try to script it.
If the user wants the page available immediately without going through the admin, that's a sign the page should be in the theme as a non-CMS page (rare — usually privacy/terms boilerplate). Even then, it has to land via the page system to get a URL — there's no "static page" route. Push back if it doesn't fit the model.
When the user says "the styling broke"
Walk this in order:
- Did Tailwind compile? Check the compiled
styles.cssexists in the deployed version dir (/data/cms/themes/<code>/<version>/). - Did
@sourceglob the right paths? A wrong relative path = empty utility set. See tailwind-and-styles.md →@source. - Did
Website.themeVersionget bumped? An old version is still activated until the operator clicks "Activate" on the new row in the Theme tab. - Browser cache? Hard reload before assuming the deploy is broken.
- CSP blocking inline styles? Look at the browser console.
Don't cache:clear Symfony — the failure is almost never a Symfony
cache miss in theme work.
When you need to test locally
# In the theme repo:
<ceems checkout>/apps/public/bin/tailwindcss -i styles.src.css -o styles.css --watch
The local cluster is --context local. The cms-public pod mounts the
NFS theme dir; if you copy your theme files into
/data/cms/themes/<code>/<version>/ on the local node, the public
site picks them up (Twig is in auto_reload mode in dev).
For testing without going through Gitea + worker dispatch:
# Compile + sync against the local pod's NFS mount.
# Adjust paths per your local cluster setup.
kubectl --context local exec -n cms cms-public-0 -c cms-public -- \
php bin/console app:theme:compile <themeCode> <version>
Don't commit experimental changes you only made for local testing — make the test changes in the live theme repo and verify deploy flow works end to end before tagging.
Git workflow expectations
- Each theme repo is independent. A change to
svbeautiqueis one commit + one tag (v0.4.2etc.) in its repo. Don't try to cross-link commits. - Tags drive deploys:
git tag v0.4.3 && git push origin v0.4.3. The admin's Theme tab picks up the new tag and shows it in the version picker. - Never push without committing all the files Tailwind expects
to scan. Pushing
templates/pages/new.html.twigbut forgettingtemplates/_partials/foo.html.twig(referenced viainclude) produces a deployed theme that throws TemplateNotFoundError on every render. Test deploy locally first.
When the task is ambiguous
Default to:
- Read the existing theme. The repo has its own conventions — font naming, container widths, color tokens. Match them.
- Don't introduce new patterns the theme hasn't used yet
(e.g. don't add a
_shared/_macros.html.twigimport if the theme has been fine without one). - Ask the user if you're about to add a new field type, new helper category, or new top-level layout block. Those are choices with downstream consequences.
What good looks like
A theme PR (or commit) for a typical page-add task:
- One new template file under
templates/pages/. - Maybe a new partial under
templates/_partials/(if reusable). - Maybe a few new utility classes referenced in
styles.src.css— added there if the theme uses@layer utilities, otherwise as arbitrary Tailwind classes inline ([data-x="y"]:bg-cream, etc.). - No
composer.jsonchanges, nopackage.jsonchanges, no PHP, no Doctrine, no migration, no edits to other repos.
If the diff includes anything outside that list, that's a signal — either the task scope changed, or you've drifted into a backend concern that should be a separate PR in the right repo.
Things to escalate, not work around
- "I want a new field type" → cms-core release. Read
field-types.md → adding a new field type
and tell the user the steps; don't fake it with
embed. - "I want a new Twig helper" → cms-core release. Don't try to compose the behaviour from existing helpers if the user explicitly wants the helper itself.
- "The cookie wall doesn't show on a tenant" → that's an operator
config (
Website.cookieSettings.enabled), check operators/index.md → General. - "Forms aren't sending mail" → mail-from chain. Walk operators/security.md → mail-from refusal model; the fix is admin-side, not a theme change.
- "Add
npm install <foo>to the theme" → no. Ask why; if the answer is "I need a JS library", that's a frontend module in cms-public. - "Add a database column to remember X per page" → no. Page metadata is fixed in cms-core; persistent per-page state needs an entity.
When in doubt about scope, ask before editing.