Skip to content

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.
  • currency is an HTML entity (&pound;), so render it with th: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 .js files get their strings through data-msg-* attributes on an element the template renders (see unifiedCheckout, 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-labels feeds 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 .default for any other country. The template renders one hidden element per country; checkout.js reads them.
  • Confirmation prompts use th:data-confirm="#{…}" with onsubmit="return confirm(this.dataset.confirm)", not a literal inside confirm('…').

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…) (in common, package resources), not IllegalArgumentException. It is a MessageSourceResolvable, so the controller or extension that shows it calls messageSource.getMessage(e, locale); getMessage() stays English for logs. Arguments are shown to the shopper: no internal ids or enum names (log those instead). ReturnException extends it. A result that carries text (e.g. ReactivationResult) holds a MessageSourceResolvable the same way.
  • Payment errors go out through PaymentResponse.failed(key, english); the HTTP boundary (PaymentExtension, unified checkout confirm) resolves shopperError(messageSource, locale), so internal codes stay in error for 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 from basket.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 to displayName / 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.