3 The Rosetta Stone - Translate anything Shared by Yesu Chum, Houston's First Baptist Church 6 days ago 16.6 CMS, Event, General Intermediate 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. Go to Admin Tools > General Settings > Defined Types Create a new Defined Type, I named ours Page Translation Add a Text attribute with key TranslatedText, mark it Required and Show on Grid 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. Go to Admin Tools > System Settings > Entity Attributes Filter to Entity Type: Registration Template 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. Go to Admin Tools > CMS Configuration > Lava Shortcodes Create a new shortcode with these settings: Name: Translate Tag Name: translate Tag Type: Inline Enabled Lava Commands: RockEntity 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) 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. Navigate to your Event Registration page (usually Page Id 403) In the Main zone, add a new block between Registration Template Detail and Registration Instance List Block Type: Entity Attribute Values Name it something like Registration Template Attribute Values 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. Go to the page that has your Registration Entry block (the actual registration form page) Open the block settings and find the Pre-HTML field 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!