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.

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.


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.
  • 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/permission issues before going live.

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. You can even set-up a workflow to allow your staff to contribute without direct access to the defined types/sql command. If you find issues or have ideas for improvements, let me know!