Templating Bootstrap components as macros

The design change was to add a footer to every card. Nineteen templates contained card markup, no two of them identical, and three of them turned out to be cards that had been copied from a modal. It took a day and a half and two of them were missed.

The symptom

$ grep -rl 'class="card' templates/ | wc -l
19

$ grep -rh 'class="card' templates/ | sort | uniq -c | sort -rn
      6 <div class="card">
      4 <div class="card mb-3">
      3 <div class="card shadow-sm">
      2 <div class="card mb-3 shadow-sm">
      2 <div class="card card-body">      ← not a card, a well
      2 <div class="card h-100">

Six variants of a thing that is conceptually one thing, and the two card-body ones are a different component wearing the same class. Nothing here is wrong exactly — every one of those is valid Bootstrap — and collectively it means the design system exists only in people’s heads.

Why it happens

A CSS framework gives you classes. A class is not a component: it has no name in the template language, no parameters, no defaults and no way to be changed in one place. The unit of reuse is a paragraph of markup that gets copied, and copied markup diverges — that is not a discipline failure, it is the predictable behaviour of the only mechanism available.

JavaScript frameworks solved this years ago by making a component the unit, which is a large part of why people reach for React on sites that do not otherwise need it. A server-rendered template language has the same primitive available and almost nobody uses it.

The fix

A macro per component

{% macro card(title, body, footer=null, classes='') %}
  <div class="card {{ classes }}">
    {% if title %}
      <div class="card-header"><h3 class="h5 mb-0">{{ title }}</h3></div>
    {% endif %}

    <div class="card-body">{{ body }}</div>

    {% if footer %}
      <div class="card-footer text-muted">{{ footer }}</div>
    {% endif %}
  </div>
{% endmacro %}

{{ card('Recent orders', orders_table, footer=pagination) }}

The footer that took a day and a half is now a parameter and a two-line block, applied everywhere the macro is used. The heading level being fixed inside the macro is deliberate and is the sort of thing that is impossible to enforce with classes alone.

Variants as arguments, not as separate macros

{% macro button(label, variant='primary', size=null, type='button',
                 disabled=false, icon=null) %}
  <button type="{{ type }}"
          class="btn btn-{{ variant }}{% if size %} btn-{{ size }}{% endif %}"
          {% if disabled %}disabled aria-disabled="true"{% endif %}>
    {% if icon %}<span class="{{ icon }}" aria-hidden="true"></span> {% endif %}
    {{ label }}
  </button>
{% endmacro %}

The aria-disabled alongside disabled is the kind of detail that is right in one place and missing in eighteen when the markup is copied. That is most of the argument for doing this at all: accessibility attributes are exactly the things people leave out under time pressure, and a macro makes leaving them out require a deliberate edit.

Where a macro stops being useful

The failure mode is a macro that has grown eleven parameters and a conditional for each, at which point reading the call site tells you nothing and the macro body is unmaintainable.

{# the eleven-parameter macro, which is a sign not a solution #}
{{ table(rows, columns, sortable, sort_by, sort_dir, paginate,
         per_page, empty_message, row_class, header_class, striped) }}

{# what it wanted to be: a smaller macro plus a caller block #}
{% macro table(columns) %}
  <table class="table">
    <thead><tr>{% for c in columns %}<th>{{ c }}</th>{% endfor %}</tr></thead>
    <tbody>{{ caller() }}</tbody>
  </table>
{% endmacro %}

{% call table(['SKU', 'Price']) %}
  {% for row in rows %}<tr><td>{{ row.sku }}</td></tr>{% endfor %}
{% endcall %}

The call block is the template equivalent of passing children, and it is the escape valve that stops the parameter list growing. Anything genuinely variable in structure belongs in the block; anything genuinely variable in configuration belongs in a parameter. Getting that boundary right is the whole design of a macro library.

Four or five parameters is where I start looking for the split, and it is a soft limit rather than a rule. The harder signal is a parameter that only makes sense in combination with another one.

Keeping the build honest

A macro library is a dependency, and treating it as one — versioned, with a compiled output — is what stops it becoming nineteen copies again.

$ npm run build:templates
  macros/card.njk       → dist/card.html
  macros/button.njk     → dist/button.html
  macros/alert.njk      → dist/alert.html

# and the render test, which is the whole quality gate
$ npm test
  ✓ card renders a header only when a title is given
  ✓ button emits aria-disabled alongside disabled
  ✓ alert has role="alert"

Rendering each macro against a set of arguments and snapshotting the result is a genuinely good use of snapshot testing — the output is small, stable and exactly the thing that must not change silently. It also documents the component: the snapshot file is the clearest available answer to what a card looks like.

Verifying it worked

$ grep -rc 'class="card' templates/ | grep -v ':0' | wc -l
0                          # no raw card markup left

$ grep -rc 'card(' templates/ | grep -v ':0' | wc -l
19

$ backstop test
  ✓ 40 of 40                # rendered output unchanged

# and the change that started this, done properly:
$ git diff --stat macros/card.njk
 macros/card.njk | 4 ++++

Four lines in one file for the change that previously took a day and a half is the number that justifies the work. The visual regression run is what makes the migration safe: nineteen call sites replacing six variants of markup is a change that should produce no visual difference at all, and any pixel that moved is a variant that was doing something on purpose.

What this costs

An abstraction over an abstraction, and a build step in a project that may not have had one. Bootstrap is already a layer over CSS; the macro library is a layer over Bootstrap, and somebody new now has to learn both. Keeping the macros thin — no logic beyond presence checks, no data fetching, no business rules — is what keeps that learnable, and it is a discipline that erodes quickly if the first exception is allowed.

The honest counter-argument is that the framework will change and the macros will have to be rewritten anyway, which is true and is the wrong comparison: without them the same change is nineteen files instead of one. The real risk is a macro library that grows to cover components used once, at which point it is a dialect rather than a set of shortcuts. Adding a macro only on the second or third use is a rule worth having from the start.