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.twig emits 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.

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') and cms_gallery('name', 'lightbox') register themselves. With cms_gallery(..., {lightbox: true}), both register.

  • slideshow: the slideshow field's partial registers it — so {{ field('hero', 'slideshow') }} is enough.

  • compare: same, the compare field'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 registers lightbox unless 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 the vehicle_lookup module 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 puts data-cms-highlight on 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, under public/assets/vendor/highlightjs/, and only the grammars a page uses are fetched. Token colours are --cms-highlight-keyword, -string, -number, -title, -variable, -comment and -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-motion for 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.