Simple way to provide translated texts in scripts

That is a totally cool idea, considering how JavaScript’s built-in localisation features are non-existant. However, and please forgive me for saying so, the concrete implementation has a number of weak points that I think need to be remedied for a localisation scheme to be more widely adopted.

  1. Extending prototypes of objects directly is widely considered A Bad Idea™ in JavaScript. In fact, many frown on this kind of extension in general, but if done, prototype extension should happen through Object.defineProperty() to avoid problems with enumeration (see this Stack Overflow discussion).
  2. Extending the Object prototype is a particularly bad idea because that pollutes the global object namespace. Object is not a special type (like Hashes or Dictionaries in other programming languages): almost every single JavaScript object (with a lower case “o”) inherits from Object. Adding to the Object prototype means you have also added that property to Numeric, String, Boolean, Array etc. Oh, and many JS engines have strange edge cases when you directly extend the root of the object hierarchy; JavaScript Core contexts, which is what Scriptable runs your scripts in, are no exception in my experience.
  3. The implementation fails to provide a default when no translation is available at all; instead, it hard stops the whole script (with a Script.complete() call). It is far more common to simply return the key in such situations, which would allow at least a minimum display variant if the calling app / script so chooses (ever seen an app telling you to tap btnLabelDismiss for textLabelDismissReasons? That).
  4. Good localisation solutions also allow insertion of variable values into localised strings at runtime. Natural language grammar varies widely, you cannot ever assume that stringing bits of localised text together with a variable in a fixed position will result in something idiomatic (I’m a German speaker using a lot of localised US software. Trust me, I know this all too well).
  5. The language lookup uses the wrong value: Device.preferredLanguages(), despite the misleading name it inherited from macOS back when it still called itself “OS X”, is not a user settable list of preferred languages; what it is is the list of system language keyboards installed. Their codes do not necessarily match actual language codes, and their being installed does not indicate the user wants to see a localisation in that language. For instance, on my devices, the call returns [de-DE, en-DE, fr-DE, ja-JP]. As you can see, the second and third are not actually valid language codes, and the fourth one is installed because I am trying to learn the language and cannot input its characters without it; I would be quite flummoxed by a Japanese localisation, considering my current grasp of the language. What you want is Device.language(), which returns the language the device is actually set to, in a format Apple calls a Language ID, a slightly odd mix of ISO 639 language codes and IETF composite language codes. That, BTW, is a single value which has the benefit of allowing you to forego the whole looping and to use far more efficient simple key lookup for message translations.
  6. Finally, fallback needs to be more fine grained: if a user has fr-CA as their Language ID (Canadian French aka Québecois), the fr localisation should be used when the more specific one is not available; falling back to English when a French localisation is available is not the correct thing to do in that case – and I am not even talking politics :innocent: –, nor is forcing translators to repeat everything from the fr localisation.

That’s a lot of stuff to keep in mind, so I had a stab at it for my own usage while ago. The core of it is a Localization class:

// Localize messages and UI text into device language using {strings}.
class Localization {
  constructor (strings) {
    this.language = Device.language()
    let group = this.language.split(/[-_]/)[0]
  
    // baseline and fallback is (US) English
    let langDefault = 'en-US'
    let groupDefault = 'en'

    this.strings = strings[groupDefault] || {}
    if (strings[langDefault]) {
      this.strings = Object.assign(this.strings, strings[langDefault])
    }
    // but prefer strings from the language group (e.g 'fr')
    if (group !== groupDefault && strings[group]) {
      this.strings = Object.assign(this.strings, strings[group])
    }
    // or, even better, from the precise language (e.g. 'fr-CA')
    if (this.language !== langDefault && this.language !== group && strings[this.language]) {
      this.strings = Object.assign(this.strings, strings[this.language])
    }
  }
    
  // get the localized string for {key}, falling back to {key} itself if no
  // match is found in the current localization. Named placeholders with the
  // syntax %{name[:default text]} can be specified and will be replaced by the
  // values for key {name} in {replacements} (if provided); umatched placeholders
  // will be replaced by {default text} (if specified).
  string (key, replacements) {
    let str = this.strings[key] || key
    if (str.includes('%{')) {
      if (replacements != null) {
        // replace matching placeholders
        str = Object.keys(replacements).reduce((acc, cur) => {
          if (replacements[cur] != null) {
            let regex = new RegExp(`%\\{${cur}(:[^}]+)?\\}`, 'g')
            acc = acc.replace(regex, replacements[cur])
          }
          return acc
        }, str)
      }
      // replace unmatched placeholders with provided defaults
      str = str.replace(/%\{[^:}]+:([^}]+)\}/g, '$1')
    }
    return str
  }
}

which allows you to do something like this:

const strings = {
  en: {
    promptTitle:'Password',
    promptMessage: 'Please enter your password for use by %{source:the source app}.'
  },

  de: {
    promptTitle:'Passwort',
    promptMessage: 'Bitte Passwort für die Verwendung durch %{source:die aufrufende App} eingeben.'
  }
}

const l8n = new Localization(strings)
let msg = l8n.string('promptMessage', {source: 'Scriptable'})

and get a message localised in the next best match for your device language (or English if nothing else matches) with “Scriptable” inserted at the grammatically correct spot.

As this class is a bit unwieldy to insert verbatim everywhere, I suggest you use my module importing hack and wrap the class code into a module, i.e. its own file with the following content:

const exports = {
  Localization: // replace comment by the full class code shown above
}

then require() it as per the linked post.

2 Likes