Client-Side Page Translation with a Lava Shortcode

Need your registration pages (or really any page) in another language? This recipe uses a Lava shortcode, a Defined Type, and a little client-side magic to swap text on the fly. Everything lives inside Rock, just maintain your translation pairs and let the shortcode do the rest.

What Does This Thing Do?

It lets you maintain translation pairs in a Rock Defined Type, flip a switch on a Registration Template, and have the page render in the target language automatically. The shortcode walks the DOM, finds matching English text, and swaps it out: buttons, placeholders, and all.

How It Works

  • Translations live in a Defined Type. Easy to manage, no code after it is set up.
  • A Language attribute on the Registration Template controls which language to use.
  • Pre-HTML on the registration block detects the language and drops in the shortcode.
  • The shortcode outputs a script that swaps text nodes, button labels, and placeholders.
  • A MutationObserver keeps translating as Rock dynamically updates the page.

What Makes It Nice

  • It's all Rock + vanilla JS, nothing to install or maintain outside your instance.
  • Works with Obsidian and WebForms registration blocks.
  • Staff can add and update translations without touching code.
  • The shortcode is reusable anywhere, not just registrations.

The Setup in 5 Steps

Work through these in order. Each one builds on the last.

Step 1 - Create the Defined Type

This is where all your translation pairs live. Think of each Defined Value as one row in a lookup table: English in, translated text out.

  1. Go to Admin Tools > General Settings > Defined Types
  2. Create a new Defined Type, I named ours Page Translation
  3. Add a Text attribute with key TranslatedText, mark it Required and Show on Grid
  4. Start adding values (details on each field below)

How the Fields Map

Each Defined Value uses three fields. Here's what goes where:

Field What It Holds Example
Value The original English text you want translated. This is what the script searches for on the page. First Name
Description The language code for the target language. This is how the shortcode filters, it only pulls values where the Description matches the language parameter. es
TranslatedText (attribute) The translated string that replaces the English text on the page. Nombre

So if you need to translate "First Name" into both Spanish and French, you'd add two Defined Values, both with a Value of First Name, but one with Description es and TranslatedText Nombre, and another with Description fr and TranslatedText Prénom. The shortcode filters by the language code at runtime so only the right set of pairs gets loaded.

You can translate anything that appears as visible text on the page: field labels, button text, headings, instructions, validation messages, you name it. Just add a Defined Value for each phrase you want swapped.

Never Include Periods in Your Value Field

The translation script uses word-boundary regex matching to find phrases on the page. Periods are not word-boundary characters, which means a Value like Registration Closed. will never match the text on the page. Always enter phrases without trailing periods in the Value field. If the text on the page ends with a period, the match still works, the period just can't be part of the Value you enter.

Example Defined Values

Here's what a handful of entries might look like for Spanish:

Value Description TranslatedText
Registration Closed es Registro Cerrado
1 remaining es 1 disponible
First Name es Nombre
Last Name es Apellido
remaining es disponibles
Next es Siguiente

Notice 1 remaining is longer than remaining so it sorts higher. That way the singular case gets matched first and "remaining" catches the plural (e.g. "3 remaining" → "3 disponibles").

Important: Sort Order Matters

You need to sort values from longest string to shortest. This prevents shorter phrases from matching inside longer ones (e.g. "Name" matching before "First Name" and leaving you with "First Nombre"). Run this SQL to auto-sort, just swap 123 with your Defined Type Id:

WITH Ordered AS (
    SELECT
        Id,
        ROW_NUMBER() OVER (
            ORDER BY LEN(Value) DESC
        ) - 1 AS NewOrder
    FROM DefinedValue
    WHERE DefinedTypeId = 123
)
UPDATE dv
SET dv.[Order] = o.NewOrder
FROM DefinedValue dv
INNER JOIN Ordered o ON o.Id = dv.Id;

Step 2 - Add the Language Entity Attribute

This adds a Language dropdown to every Registration Template so staff can pick which language that template's registrations should render in.

  1. Go to Admin Tools > System Settings > Entity Attributes
  2. Filter to Entity Type: Registration Template
  3. Add a new Single-Select attribute:
    • Key: Language
    • Values: en,es (add more language codes as needed)
    • Default Value: en
    • Mark it Required

Step 3 - Create the Translate Shortcode

This is the engine. The shortcode pulls translation pairs from your Defined Type and outputs a script that does the swapping.

  1. Go to Admin Tools > CMS Configuration > Lava Shortcodes
  2. Create a new shortcode with these settings:
    • Name: Translate
    • Tag Name: translate
    • Tag Type: Inline
    • Enabled Lava Commands: RockEntity
  3. Add two parameters:
    • language - default value: es (or whatever your primary target language is)
    • scope - default value: body (CSS selector for what part of the page to translate)
  4. Paste this into the shortcode markup, change 123 to your Defined Type Id:
{% assign language = language | Default:'en' %}
{% assign scope = scope | Default:'body' %}

{% definedvalue where:'DefinedTypeId == 123 && Description == "{{ language }}"' securityenabled:'false' sort:'Order' iterator:'translationValues' %}
    {% if translationValues != empty %}
<script>
(function() {
    var rockTranslationPairs = [
            {% for translationEntry in translationValues %}
                {% assign translatedText = translationEntry | Attribute:'TranslatedText' %}
                {% if translatedText != '' %}
        { english: {{ translationEntry.Value | ToJSON }}, translated: {{ translatedText | ToJSON }} },
                {% endif %}
            {% endfor %}
    ];

    var translationScope = document.querySelector('{{ scope }}');
    if (!translationScope) return;

    var compiledPatterns = rockTranslationPairs.map(function(pair) {
        var escapedEnglish = pair.english.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        return {
            pattern: new RegExp('\\b' + escapedEnglish + '\\b', 'g'),
            translated: pair.translated
        };
    });

    var translationPending = false;

    function translateTextNodes() {
        var treeWalker = document.createTreeWalker(translationScope, NodeFilter.SHOW_TEXT);
        var currentNode;

        while (currentNode = treeWalker.nextNode()) {
            var originalText = currentNode.textContent;
            var translatedNodeText = originalText;

            for (var i = 0; i < compiledPatterns.length; i++) {
                compiledPatterns[i].pattern.lastIndex = 0;
                translatedNodeText = translatedNodeText.replace(
                    compiledPatterns[i].pattern,
                    compiledPatterns[i].translated
                );
            }

            if (translatedNodeText !== originalText) {
                currentNode.textContent = translatedNodeText;
            }
        }

        translationScope.querySelectorAll('button, .btn, [type="submit"]').forEach(function(buttonElement) {
            var buttonText = buttonElement.textContent.trim();
            for (var i = 0; i < rockTranslationPairs.length; i++) {
                if (rockTranslationPairs[i].english === buttonText) {
                    buttonElement.textContent = rockTranslationPairs[i].translated;
                    break;
                }
            }
        });

        var placeholderElements = translationScope.querySelectorAll('input[placeholder], textarea[placeholder]');
        placeholderElements.forEach(function(inputElement) {
            var placeholderText = inputElement.getAttribute('placeholder');
            for (var i = 0; i < rockTranslationPairs.length; i++) {
                if (rockTranslationPairs[i].english === placeholderText) {
                    inputElement.setAttribute('placeholder', rockTranslationPairs[i].translated);
                    break;
                }
            }
        });
    }

    function scheduleTranslation() {
        if (translationPending) return;
        translationPending = true;
        requestAnimationFrame(function() {
            translateTextNodes();
            translationPending = false;
        });
    }

    document.addEventListener('DOMContentLoaded', translateTextNodes);
    new MutationObserver(scheduleTranslation).observe(translationScope, { childList: true, subtree: true });
})();
</script>
    {% endif %}
{% enddefinedvalue %}

Step 4 - Add the Entity Attribute Values Block

This gives your staff a clean UI to toggle the language on a Registration Template without digging into settings.

  1. Navigate to your Event Registration page (usually Page Id 403)
  2. In the Main zone, add a new block between Registration Template Detail and Registration Instance List
  3. Block Type: Entity Attribute Values
  4. Name it something like Registration Template Attribute Values
  5. Set Entity Type to Registration Template

Now when staff view a Registration Template, they'll see the Language dropdown right there. Flip it to es and every registration under that template gets translated.

Step 5 - Add the Pre-HTML to the Registration Block

This is the glue. The Pre-HTML on the registration block figures out which Registration Template is in play, checks the Language attribute, and calls the shortcode if it's not English.

  1. Go to the page that has your Registration Entry block (the actual registration form page)
  2. Open the block settings and find the Pre-HTML field
  3. Paste this in:
{% assign registrationInstanceId = 'Global' | PageParameter:'RegistrationInstanceId' | Default:'0' | AsInteger %}
{% assign registrationId = 'Global' | PageParameter:'RegistrationId' | Default:'0' | AsInteger %}
{% assign slug = 'Global' | PageParameter:'slug' | Default:'' | SanitizeSql %}
{% assign thisRegistration = null %}

{% if registrationId > 0 %}
    {% registration id:'{{ registrationId }}' securityenabled:'false' %}
        {% assign thisRegistration = registrationItems | First | Property:'RegistrationInstance' %}
    {% endregistration %}
{% endif %}

{% if thisRegistration == null and slug != '' %}
    {% eventitemoccurrencegroupmap where:'UrlSlug == "{{ slug }}"' iterator:'Mappings' securityenabled:'false' %}
        {% assign mapping = Mappings | First %}
        {% if mapping %}
            {% if mapping.Group.Name and mapping.Group.Name != '' %}
                {{ mapping.Group.Name | SetPageTitle }}
            {% endif %}
            {% assign registrationInstanceId = mapping.RegistrationInstanceId %}
        {% endif %}
    {% endeventitemoccurrencegroupmap %}
{% endif %}

{% if thisRegistration == null and registrationInstanceId > 0 %}
    {% registrationinstance id:'{{ registrationInstanceId }}' securityenabled:'false' %}
        {% assign thisRegistration = registrationinstanceItems | First %}
    {% endregistrationinstance %}
{% endif %}

{% assign registrationTemplateId = thisRegistration.RegistrationTemplateId %}

{% registrationtemplate id:'{{ registrationTemplateId }}' securityenabled:'false' %}
    {% assign thisTemplate = registrationtemplateItems | First %}
{% endregistrationtemplate %}

{% assign templateLanguage = thisTemplate | Attribute:'Language','RawValue' | Downcase | Default:'' %}
{% if templateLanguage != '' and templateLanguage != 'en' %}
    {[ translate language:'{{ templateLanguage }}' ]}
{% endif %}

Using It Outside of Registrations

The shortcode doesn't care where you use it. You can drop it into any HTML block, Lava template, or page. Here are some examples:

Usage Examples

Translate the entire page to Spanish (using defaults):

{[ translate ]}

Translate only a specific section:

{[ translate language:'es' scope:'#event-registration' ]}

Translate to French:

{[ translate language:'fr' ]}

The scope parameter takes any valid CSS selector, so you can target by ID, class, or whatever you need. By default it targets body which translates the whole page.


Going Further, Site-Wide Language Toggle

The registration setup above is great when you know ahead of time that a specific template should always render in a certain language. But sometimes you want to give visitors the ability to switch languages themselves, and you want that preference to stick as they move around the site. That's where a language toggle comes in.

There are two ways to set this up. They both use a browser cookie to remember the visitor's preference and a toggle link to let them switch. The difference is where the shortcode fires and which pages it affects.

Approach A: Page by Page

Best if you only want translation on specific pages, not the whole site. You opt individual pages in by setting a flag on them. Everything else stays English.

Good for: ministries with a mix of English and bilingual pages, or when you're rolling out translation gradually.

Approach B: Whole Site

Best if you want every page on the site to be translatable. The toggle shows up everywhere and applies to the whole site from the start.

Good for: sites intentionally built for a bilingual audience, or when you're starting fresh and want full coverage from day one.

Both approaches share the same cookie-based toggle mechanism. The cookie is named site-language and stores the language code (es for Spanish). When it's set, the shortcode fires and translation kicks in. When it's absent or cleared, the page stays in English. The toggle link sets or clears the cookie and reloads the page.

Both also require the ReadCookie Lava filter.

Approach A: Page-by-Page Translation

This approach uses a Boolean entity attribute on the Page entity to act as an opt-in flag. Pages where the flag is set to Yes get the language toggle in the header. Pages where it's not set stay English-only, the toggle doesn't even appear.

Setting Up the Page Attribute

  1. Go to Admin Tools > System Settings > Entity Attributes
  2. Filter to Entity Type: Page
  3. Add a new Boolean attribute:
    • Name: Page Translation Toggle
    • Key: PageTranslationToggle
    • Field Type: Boolean
    • Default Value: No (unchecked)
  4. If this is for a specific site only, set the Qualifier Field to SiteId and enter your Site Id as the Qualifier Value. This keeps the attribute from cluttering up page settings across all your sites.

Once the attribute exists, navigate to any page you want to opt in, open its properties (via the Admin Bar or Page Map), find the Page Translation Toggle attribute, and check it to Yes. That's all it takes to enable translation on that page.

The Zone Lava

Drop this into a shared zone that appears on every page of the site, the site header is the natural fit. The code checks whether the current page has the toggle enabled before doing anything, so on pages where the attribute isn't set it outputs nothing at all.

{%- assign translationEnabled = CurrentPage | Attribute:'PageTranslationToggle' | AsBoolean -%}
{%- if translationEnabled -%}
    {%- assign langCookie = 'site-language' | ReadCookie -%}
    {%- if langCookie == 'es' -%}
        {[ translate language:'es' ]}
        <a href="#" class="btn btn-sm ml-2 js-lang-toggle" data-lang="en">English</a>
    {%- else -%}
        <a href="#" class="btn btn-sm ml-2 js-lang-toggle" data-lang="es">Español</a>
    {%- endif -%}
    {% javascript %}
        document.addEventListener('click', function(e) {
            var toggle = e.target.closest('.js-lang-toggle');
            if (!toggle) return;
            e.preventDefault();
            var lang = toggle.getAttribute('data-lang');
            document.cookie = 'site-language=' + lang + '; path=/; max-age=' + (lang === 'es' ? 60 * 60 * 24 * 30 : -1);
            window.location.reload();
        });
    {% endjavascript %}
{%- endif -%}

How It Works

  • On every page load, Lava reads the PageTranslationToggle attribute on the current page. If it's not Yes, nothing renders.
  • If it is Yes, Lava then reads the site-language cookie. If it's set to es, the translate shortcode fires and the toggle shows an English button so the visitor can switch back. If the cookie is absent or set to anything else, only the Español button shows, no translation fires yet.
  • Clicking the toggle sets (or clears) the cookie and reloads the page, at which point the shortcode either fires or doesn't based on the cookie value.
  • The cookie lasts 30 days when switching to Spanish and is immediately expired when switching back to English.

Approach B: Whole-Site Translation

This approach skips the page-by-page flag entirely. The toggle lives in the header and is active on every page. Visitors can switch languages from anywhere on the site and their preference carries across every page they visit.

This is more setup upfront, you'll want a solid library of translation pairs covering all your common site text, not just registration fields, but it's simpler to manage once it's running since there's nothing to toggle per page.

The Zone Lava

Drop this into your site header zone. There's no page attribute to check, it runs on every page.

{%- assign langCookie = 'site-language' | ReadCookie -%}
{%- if langCookie == 'es' -%}
    {[ translate language:'es' ]}
    <a href="#" class="btn btn-sm btn-outline-light no-grow ml-2 js-lang-toggle" data-lang="en">English</a>
{%- else -%}
    <a href="#" class="btn btn-sm btn-outline-light no-grow ml-2 js-lang-toggle" data-lang="es">Español</a>
{%- endif -%}
{% javascript %}
    document.addEventListener('click', function(e) {
        var toggle = e.target.closest('.js-lang-toggle');
        if (!toggle) return;
        e.preventDefault();
        var lang = toggle.getAttribute('data-lang');
        document.cookie = 'site-language=' + lang + '; path=/; max-age=' + (lang === 'es' ? 60 * 60 * 24 * 30 : -1);
        window.location.reload();
    });
{% endjavascript %}

How It Works

  • On every page load, Lava reads the site-language cookie directly, no page attribute check needed.
  • If the cookie is es, the translate shortcode fires site-wide and the toggle shows an English button. If the cookie is absent, only the Español button renders.
  • The cookie and reload behavior are identical to Approach A. The only difference is that this code has no conditional wrapper, it always outputs something, on every page.
  • Because translation runs everywhere, make sure your Defined Type has pairs for all the shared site text you care about (navigation labels, footer text, common headings, etc.) not just registration-specific phrases.

Troubleshooting

  • Sort order is everything. If "Name" matches before "First Name" you'll get "First Nombre" instead of the full translated phrase. Run that SQL after adding new values.
  • Periods in the Value field break matching. The word-boundary regex treats a period as a non-word character, so a Value of Registration Closed. will never find a match on the page. Always leave trailing periods out of your Value entries.
  • RockEntity command is required. The shortcode needs it to query the Defined Values. Make sure it's enabled on the shortcode itself.
  • securityenabled:'false'. All the entity tags use this since the Pre-HTML runs in a public context. Without it, anonymous visitors won't see the translation kick in.
  • MutationObserver handles dynamic content. When Rock updates the DOM (next step in registration, validation messages, etc.), the observer catches it and re-translates.
  • Test with a private browser window. Make sure you test as an anonymous user to catch any security or permission issues before going live.

Coming Soon, Let Non-Admins Manage Translation Pairs

Managing the Defined Type directly works fine, but it requires admin access and a manual SQL run every time you add new pairs. There's a companion recipe in the works that gives non-admin staff a safe, guided UI to add and edit translation pairs, and it handles the sort order automatically as entries are added or changed. No SQL required.

Access to Add and Sort Defined Values with Guardrails, coming soon on Rock Community →

Download: Starter Spanish Lava Templates

The translation script handles what's on the page, but emails and confirmation text, confirmation messages, reminder emails, waitlist notifications, and similar are sent out by Rock and never seen by the script. Attached is a zip file with pre-built Spanish Lava files for these templates so you have a starting point for the content that lives outside the page.


That's It!

Five steps and you've got client-side page translation running entirely inside Rock. Add new translation pairs to the Defined Type whenever you need them, re-run the sort SQL, and you're good to go. If you find issues or have ideas for improvements, let me know!