Storefront text and message keys¶
Every word a shopper sees or hears in the Nova theme comes from the message bundle
(commerce-core/src/main/resources/messages.properties), never from template markup. That includes
screen-reader text (aria-label, visually hidden spans, alt, title), placeholders, fallbacks shown
when data is missing, and strings that JavaScript writes into the page. A site can then override any of
it in Admin → Settings → Site text, and other locales (for example messages_nl.properties) fall
back to the English value key by key.
i18n by default
Every new or changed customer-facing template, extension, fragment or script must follow this page from the start. Hard-coded shopper-visible English is treated as a bug, not a follow-up.
Writing a template¶
<!-- Plain text, attribute and parameterised text -->
<h2 th:text="#{featuredProduct.empty}">There are no featured products at the moment.</h2>
<input th:placeholder="#{search.label.products}" placeholder="Search">
<p th:text="#{blog.postedByOn(${post.author}, ${postedDate})}">Posted by Jo on 1 May</p>
<!-- Counts: one choice-format key, not "n + ' products'" -->
<span th:text="#{catalogue.productCount(${total})}">12 products</span>
- Keep the English as the static body or attribute, so the raw template still reads naturally.
- Build a sentence from one parameterised key. Concatenating fragments fixes the English word order.
- Plurals use MessageFormat
choice:catalogue.productCount={0,choice,0#No products|1#1 product|1<{0,number,integer} products}. - A value that takes arguments goes through MessageFormat, so a literal apostrophe is written
''(You haven''t…). A value with no arguments is used as written. currencyis an HTML entity (£), so render it withth:utext="#{currency}"beside the number rather than typing£.
JavaScript¶
- Inline scripts (
th:inline="javascript") read keys directly:var MSG = { saving: /*[[#{common.processing}]]*/ 'Processing…' };For runtime values, pass the pattern and replace{0}in the script. - Static
.jsfiles get their strings throughdata-msg-*attributes on an element the template renders (seeunifiedCheckout,searchBar,payment,applePayExtension). Keep an English fallback in the script, since other themes may load it without the attributes. - Third-party widgets get labels the same way:
fragment/gallery-labelsfeeds the product gallery's Splide carousel (i18n) and PhotoSwipe lightbox (closeTitle,zoomTitle, …). - Per-country wording in checkout (city / county / postcode labels) comes from
checkout.unified.address.{city|region|postal}.{country}keys, with.defaultfor any other country. The template renders one hidden element per country;checkout.jsreads them. - Confirmation prompts use
th:data-confirm="#{…}"withonsubmit="return confirm(this.dataset.confirm)", not a literal insideconfirm('…').
Text built in Java¶
Prefer handing the template raw values and letting it word them (as FacetRailModel.Group.quickValue()
does for the quick-filter pills). When Java has to produce the words itself (a JSON reply, a model value
another theme renders as-is), resolve a key with MessageSource and LocaleContextHolder.getLocale(),
passing the English as the default: see SubscriptionPaymentUpdateExtension and
FundraiserController.checkTitleAvailability. Search facet wording (platform facet names, price and
number ranges, rating thresholds) goes through SearchLabels, which the storefront and the REST API
both use; SearchLabels.ENGLISH covers code with no request.
- Errors a shopper reads ("That amount is too large", "Choose at least one item to return") are
thrown as
ShopperMessageException(key, englishDefault, args…)(incommon, packageresources), notIllegalArgumentException. It is aMessageSourceResolvable, so the controller or extension that shows it callsmessageSource.getMessage(e, locale);getMessage()stays English for logs. Arguments are shown to the shopper: no internal ids or enum names (log those instead).ReturnExceptionextends it. A result that carries text (e.g.ReactivationResult) holds aMessageSourceResolvablethe same way. - Payment errors go out through
PaymentResponse.failed(key, english); the HTTP boundary (PaymentExtension, unified checkout confirm) resolvesshopperError(messageSource, locale), so internal codes stay inerrorfor logs. - Bean validation messages use the
{key}form (@Size(message = "{login.password.size}"));WebMvcConfiguration.getValidator()resolves them from the bundle. Hibernate interpolates these, so their values don't double apostrophes and may use{min}/{max}. - Basket line attributes (
ProductTypeDecorator.populateDisplayParameters) keep their English map keys as identifiers; the line-item fragment labels each frombasket.lineAttribute.<key without spaces>(and a fixed value from<that key>.value), falling back to the raw text. - Payment method names:
PaymentMethod.getDisplayNameKey()/getDisplaySubtitleKey()name the keys the order summary resolves, falling back todisplayName/displaySubtitle.
Emails¶
Customer emails (commerce-core/.../templates/bootstrap/email/) follow the same rule, rendered in the
site's default locale (MailtrapEmailService.resolveLocale). Shared pieces (greeting, sign-off, order
labels, totals, "Questions? Email…") are email.common.*; each email has its own family
(orderConfirmationEmail.*, subscriptionEmail.*, fundraiserEmail.*, …). The HTML and the -text
companion use the same keys. The -text templates are processed in HTML mode, so [[...]] escapes
what it prints; EmailTemplateRenderer.renderText turns the entities back into characters, so plain
text reads "We'll", not "We'll". Subject lines set in Java resolve a key in the same locale (see
FundraiserEventListener); most subjects are per-site settings already. Emails that only staff receive
(fulfilment, packing, staff return and fundraiser notices) stay English, like the admin.
EmailMessageKeysTest fails on a template that uses a missing key.
The auth portal's magic link email (auth/.../templates/auth/email/magicLink) uses the same bundle
through AuthMessageSourceConfig, in the default locale of the site the OIDC client belongs to. Site
text overrides don't apply there: the commerce apps' BadgerMessageSource doesn't run in auth.
Shared keys¶
Reuse these before adding a feature-specific copy of the same word. They are grouped at the end of
messages.properties.
| Family | Use for |
|---|---|
button.* |
Actions: button.close, .previous, .next, .apply, .update, .edit, .save, .delete, .view, .print, .download, .search, plus the older .cancel, .back, .go, .viewDetails, .continueShopping, .signIn, .signOut |
common.* |
Non-action words: amount, date, product, status, actions, total, yes, no, notSet, note, areYouSure, required (visually hidden after a *), maxCharacters, charCounter, percent, processing, uploading, posting, selectPlaceholder, video |
product.* |
placeholder.alt, price.from, gallery.images / .thumbnails / .video, recentlyViewed |
carousel.* |
previousSlide, nextSlide, goToSlide |
sort.* |
Sort dropdown labels. SortOption.messageKey() names the key for each option |
catalogue.productCount, fundraiser.supporterCount / .daysLeftCount / .hoursLeftCount |
Counted phrases |
The check¶
NovaMessageKeysTest (web-mvc) fails when a Nova template uses a literal key that isn't in
messages.properties. A missing key doesn't break the page: Thymeleaf prints ??key_en_GB?? where the
text should be, which only a shopper would spot. Keys built at render time (#{${…}}, #{__…__})
are not covered, so check those pages by eye.
Themes that layer on Nova (Brock, Depot, Pop, Spec, Atelier) keep their own <theme>.* keys for
wording that differs from Nova; see each theme's page.