A common form translation mistake, and how to avoid it using DOM Range

There’s a recurring mistake I see in Marketo Forms translation/localization logic that calls for a li’l lesson in best practices.

People typically do something like this:

// language can be output by CMS or Marketo token
const currentLanguage = "de"; 

(function(){
  const translations = {
    "de" : [
      {
        "mktoFieldId" : "FirstName",
        "label" : "Vorname"
      },
      {
        "mktoFieldId" : "Email",
        "label" : "E-Mail"
      },
      {
        "mktoFieldId" : "Country",
        "label" : "Land"
      },
      {
        "mktoFieldId" : "Phone",
        "label" : "Telefonnr."
      }
    ]
  };
  
  MktoForms2.whenRendered(function(mktoForm){
    let formEl = mktoForm.getFormElem()[0];
    
    translations[currentLanguage].forEach(function(translate){
      let label = formEl.querySelector("label[for='" + translate.mktoFieldId + "']");
      if(!label) return;
      label.textContent = translate.label;             
    });
  });
})();

Pretty simple: declare a collection at the top, then a tiny bit of code to iterate over the form and look for translatable text. (In real life, you would cover more than just <label> elements, I’m simplifying here.)

Then set textContent to the translated text. Sometimes people use innerHTML, but that doesn’t avoid the bug we’re about to see.

That code turns this English/default form:

Into a German variant:

Hmm. Almost right. But what happened to the red asterisk (*) for the required fields?

It’s no mystery once you look at the form HTML:

<div class="mktoFieldWrap mktoRequiredField">
  <label for="Email" id="LblEmail" class="mktoLabel mktoHasWidth" style="width: 100px;"><div class="mktoAsterix">*</div>Email Address:</label>
  <div class="mktoGutter mktoHasWidth" style="width: 10px;"></div>
  <input id="Email" name="Email" maxlength="255" aria-labelledby="LblEmail InstructEmail" type="email" class="mktoField mktoEmailField mktoHasWidth mktoRequired" style="width: 150px;" aria-required="true">
  <span id="InstructEmail" tabindex="-1" class="mktoInstruction"></span>
  <div class="mktoClear"></div>
</div>
Forms 2.0 HTML fragment for Email field. Some whitespace added for readability.

See, the asterisk is in a <div> inside the <label> element. When you set the label.textContent, that replaces the entire contents of the label with a Text node. So naturally that wipes out the <div class="mktoAsterix">.

(Like I said, this isn’t solved by setting label.innerHTML. That just replaces the entire contents with the supplied HTML, again including the asterisk.)

This is where you have to grok the difference between a Text node and an Element node. See the stuff after the closing </div> but before the closing </label>?

  <label for="Email" id="LblEmail" class="mktoLabel mktoHasWidth" style="width: 100px;"><div class="mktoAsterix">*</div>Email Address:</label>
Just the <label> to highlight its Element child and Text child.

That’s a Text node! It’s not part of the <div> but it’s also not the entire inner content of the <label>. It’s just the text, and it’s a distinct part of the DOM. Every time you create a <div>something</div> you’re creating both an outer Element node and an inner Text node.

To set only the contents of the Text node, you set its nodeValue. But first you have to locate the node using Node.childNodes, which is Text-node-aware.

(The label.childNodes collection includes both child Element nodes and child Text nodes. This is different from label.children, which — a great example of ambiguous naming — is part of the Element interface so doesn’t include Text nodes. Lots to remember!)

So the label has 2 immediate child nodes: the first/index [0] is the <div> — which itself has a one child Text node with the asterisk character, but we’re leaving that whole element alone  — and the second/index [1] is the Text node.[1] So we can target the Text node only:

  MktoForms2.whenRendered(function(mktoForm){
    let formEl = mktoForm.getFormElem()[0];
    
    translations[currentLanguage].forEach(function(translate){
      let label = formEl.querySelector("label[for='" + translate.mktoFieldId + "']");
      if(label){
        let editableText = label.childNodes[1];   
        editableText.nodeValue = translate.label;
      }
    });
  });

Ah, much better:

But there’s still a problem. Remember, <label> elements can contain any number of child elements. So if the form starts like this:

With this underlying markup:

<div class="mktoFieldWrap">
  <label for="Phone" id="LblPhone" class="mktoLabel mktoHasWidth" style="width: 100px;"><div class="mktoAsterix">*</div>Phone:<br>(optional)</label>
  <div class="mktoGutter mktoHasWidth" style="width: 10px;"></div>
  <input id="Phone" name="Phone" maxlength="255" aria-labelledby="LblPhone InstructPhone" type="tel" class="mktoField mktoTelField mktoHasWidth" style="width: 150px;">
  <span id="InstructPhone" tabindex="-1" class="mktoInstruction"></span>
  <div class="mktoClear"></div>
</div>
Forms 2.0 HTML fragment for Phone field. Some whitespace added for readability.

And we want our German variant to have different inner HTML:

  const translations = {
    "de" : [
      {
        "mktoFieldId" : "FirstName",
        "label" : "Vorname"
      },
      {
        "mktoFieldId" : "Email",
        "label" : "E-Mail"
      },
      {
        "mktoFieldId" : "Country",
        "label" : "Land"
      },
      {
        "mktoFieldId" : "Phone",
        "label" : "Telefonnr.<br><em>nicht benötigt</em>"
      }
    ]
  }

That won’t work with the above code. We’ll replace the Text node’s nodeValue to leave the asterisk safe, but our HTML will be interpreted as text (since Text nodes only have text):

Now we have to get serious with DOM Range. For partial replacement of the child nodes of an element — including Text and Element nodes, with unpredictable structure other than knowing when the unpredictable stuff starts — with new HTML, Range is the way.

I won’t go deep today. But do learn the ins and outs of the Range API: it’s complex but rewarding.

In short: we create a Range starting after the asterisk and stopping at the last child node, inclusive. Then replace the Range with an HTML fragment made from the translated content:

  MktoForms2.whenRendered(function(mktoForm){
    let formEl = mktoForm.getFormElem()[0];
    
    translations[currentLanguage].forEach(function(translate){
      let label = formEl.querySelector("label[for='" + translate.mktoFieldId + "']");
      if(label){
        let editableNodeRange = document.createRange();
        let firstEditableNode = label.childNodes[1];  
        let lastEditableNode = label.lastChild;
        editableNodeRange.setStartBefore(firstEditableNode);
        editableNodeRange.setEndAfter(lastEditableNode);
        editableNodeRange.deleteContents();
       
        let newLabelFragment = editableNodeRange.createContextualFragment(translate.label);
        editableNodeRange.insertNode(newLabelFragment);
      }
    });
  });

And finally, satisfaction!

Notes

[1] The <div class="mktoAsterix"> always exists in the DOM. It’s simply hidden if the field isn’t marked Required in Form Editor.