Per-page modules
Some interactive widgets (carousel, lightbox, slideshow, …) need a small
JS + CSS pair to work. Loading those bytes on every public page is
wasteful. The cms_module() registry solves this:
- A partial that needs a module calls
{% do cms_module('<name>') %}. _shared/base.html.twigemits one<link>(at the end of<head>) and one<script type="module">(at the end of<body>) per registered module, and only those.
If a page never calls cms_module(), no JS or CSS is shipped at all.
What modules ship
| Module | What it does | Files in cms-public/public/assets/ |
|---|---|---|
| carousel | Horizontal scroll with arrows + dot indicators | carousel.js + carousel.css |
| lightbox | Click any image → fullscreen overlay | lightbox.js + lightbox.css |
| slideshow | Auto-rotating hero banner with crossfade | slideshow.js + slideshow.css |
| compare | Before/after images split by a draggable divider | compare.js + compare.css |
| imagerail | Thumbnail rail that swaps the main image on click | imagerail.js + imagerail.css |
| reviews | Google-reviews widget (server-rendered; the JS is a stub today) | reviews.js + reviews.css |
| variant_picker | Product-detail variant selector (option-type selects → variant id) | variant_picker.js + variant_picker.css |
| address_autocomplete | Checkout address autocomplete, proxied to Google server-side (no browser key) | address_autocomplete.js + address_autocomplete.css |
| portfolio | Tablist switcher for portfolio collections: arrow keys, #slug deep links |
portfolio.js + portfolio.css |
| faq | FAQ accordion: opens the question a #faq-<slug> link points at, mirrors the open one into the address bar |
faq.js + faq.css |
| vehicle | License-plate field: looks the plate up on blur, fills the hidden brand/model/year inputs | vehicle.js + vehicle.css |
| form | Trades a cached page's shared form schema for a per-visitor one when the visitor starts on the form; holds a quick submit until it is in place | form.js + form.css |
| video | video.js player: picks a rendition, handles autoplay/muted | video.js + video.css |
| highlight | Syntax highlighting (highlight.js) for <pre><code class="language-x"> blocks inside [data-cms-highlight] |
highlight.js + highlight.css |
Most of these pair with module features: imagerail and slideshow
are field render modes; reviews rides the cms_reviews() helper;
variant_picker and address_autocomplete are registered by the
catalog product page and the checkout template; portfolio, faq and
vehicle are registered by cms_portfolio(), cms_faq() and
form_licenseplate().
They load the same way, only on the pages that register them.
Each module is a plain ES module. No imports, no bundler, no importmap — the browser loads them directly.
video is the one exception, and worth understanding before you copy the
pattern. It ships a third-party library (video.js), vendored under
public/assets/vendor/videojs/ because there is no npm install in
cms-public. The build is video.core.min.js, not the default one — we
serve progressive MP4 renditions rather than HLS, so the streaming engine
would be 400 KB of dead weight (690 KB → 262 KB). video.js itself is
still a plain ES module; it just fetches the library at runtime, and only
once an element approaches the viewport. A video three screens down costs
nothing up front. Videos that autoplay are exempt — a header loop is above
the fold by definition. Upgrade steps are in that directory's README.txt.
Carousel tracks snap: pad them with scroll-padding too
The carousel track (also the reviews track in carousel mode) is a
scroll-snap-type: x mandatory scroller whose cards snap to start. If a
theme gives the track horizontal padding, set the same value as
scroll-padding-inline:
.my-reviews .cms-reviews__track { padding: 6px 2px 10px; scroll-padding-inline: 2px; }
Without it the first card sits off its snap point and the browser snap-scrolls
the track on load. Chrome counts that as a scroll and stops measuring LCP, so
PageSpeed reports NO_LCP and gives the page no performance score at all (this
is what hid cor-it.nl's score in ZenRise).
When a module loads automatically
-
carousel + lightbox:
cms_gallery('name', 'carousel')andcms_gallery('name', 'lightbox')register themselves. Withcms_gallery(..., {lightbox: true}), both register. -
slideshow: the
slideshowfield's partial registers it — so{{ field('hero', 'slideshow') }}is enough. -
compare: same, the
comparefield's partial registers it, so{{ field('roof', 'compare') }}is enough. -
video:
cms_video(source)registers it. The element still works if the module or the library never loads — it degrades to a plain<video>with poster, controls and a working source. -
portfolio:
cms_portfolio()registers it, and also registerslightboxunless you pass{lightbox: false}. Without the JS every panel stays visible as a labelled section, so a broken script shows all the photographs rather than none. -
faq:
cms_faq()registers it. The accordion is<details>, so every question still opens and closes with the script missing; the module only opens the question a#faq-<slug>link points at and keeps the address bar on the question being read. -
vehicle:
form_licenseplate()registers it, but only where thevehicle_lookupmodule is enabled. Where it is off the helper emits a plain text input and ships no JS at all. -
form:
form_end()registers it on every page whose form carries the shared schema, which is every page with a form except one that redisplays a submission. Without the script the form still works: the submit sends the visitor back once to confirm. See forms.md. -
highlight: never automatic. A theme that shows code calls
{% do cms_module('highlight') %}and putsdata-cms-highlighton the element around the rich text. Blocks with a language class of twig, html, json, css, sh, js or csv (and their usual spellings) are coloured, as they near the viewport; the rest stay plain, as does everything when the script is missing. The library is vendored like video.js, underpublic/assets/vendor/highlightjs/, and only the grammars a page uses are fetched. Token colours are--cms-highlight-keyword,-string,-number,-title,-variable,-commentand-template, set by the theme on any ancestor; the defaults suit a light code background, so a dark one (or a dark mode) sets its own.
When you call cms_module() yourself
Only when you write the markup directly without going through a shared partial. Example: a theme renders its own custom data-attribute markup that the slideshow JS already understands —
{% do cms_module('slideshow') %}
<div data-cms-slideshow data-cms-slideshow-interval="3000">
<div data-cms-slideshow-track>
<div data-cms-slideshow-slide class="cms-slideshow__slide--active">…</div>
<div data-cms-slideshow-slide>…</div>
</div>
</div>
That registers slideshow.js+css for this render. Don't call it inside a loop — once per module per page is enough.
How base.html.twig emits them
For reference (this lives in cms-core, themes don't override it normally):
{% apply cms_module_styles %}
<!DOCTYPE html>
…
{% block javascripts %}
{% for m in cms_modules() %}
<script type="module" src="{{ asset('assets/' ~ m ~ '.js') }}" defer></script>
{% endfor %}
{% endblock %}
…
</html>
{% endapply %}
A module registers while the body renders, after <head> has been
written, so the stylesheets can't be linked in a loop up there. The
cms_module_styles filter runs once the whole page has rendered and puts
a <link rel="stylesheet"> for every registered module just before
</head>, after the theme's stylesheet. The page therefore never paints
a module unstyled: before this, an above-the-fold slideshow showed its
slides stacked and then jumped. A theme overriding javascripts should
call {{ parent() }} to keep the scripts; the stylesheets come either way.
asset() adds a hash of the file (/assets/slideshow.css?v=3f9c0a1b2d),
which is what lets nginx cache these files for a year.
Module names are also the file basenames. A module called foo resolves
to /assets/foo.js and /assets/foo.css (both must exist).
Adding a new module
The point of plain ES modules is that you can drop a file in and it
works. Two files in cms-public/public/assets/:
cms-public/public/assets/myfeature.js
cms-public/public/assets/myfeature.css
myfeature.js should auto-attach on DOMContentLoaded:
class MyFeature {
constructor(root) {
// …attach handlers, store cleanup
}
}
function init() {
document.querySelectorAll('[data-cms-myfeature]').forEach((el) => new MyFeature(el));
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
Then anywhere a page or partial uses the markup, register it:
{% do cms_module('myfeature') %}
<div data-cms-myfeature>…</div>
Conventions:
- Class prefix
cms-<name>— keeps theme styles from collisions. data-cms-<name>attribute marks the root.- Sub-roles use
data-cms-<name>-<role>(e.g.data-cms-slideshow-track). - CSS uses
--cms-<name>-*custom properties for tunables. - Respect
prefers-reduced-motionfor any auto-animation. - Use
loading="lazy"on below-the-fold imagery;loading="eager"only for the LCP candidate (first slide of a hero, etc.).
What modules don't do
- No state across pages. Each module re-attaches on every page load. The site is server-rendered HTML; modules can't assume a SPA shell.
- No editor concerns. The editor strips behavioural JS — modules run
in the public render only. Editor-time decoration lives in
cms-admin/src/editor/iframeEditor.ts. - No third-party imports. A module with
import 'swiper'would need a bundler. We don't have one. Keep it vanilla.