Localizing character sheets

This article compares the internationalization method (i18n()) introduced with Kanka 1.36 for Marketplace attribute templates and my own approach, which favors maintaining a different plugin for each locale. While this may sound like more work, it presents several advantages for both convenience and quality.

Overview

Let’s start with a bit of terminology and a summarized comparison of advantages and disadvantages. I will refer to my approach as $l10n, a shorthand for localization.

Terminology

  • Localization. Sometimes synonymous with translation, localization goes beyond merely translating words by considering aspects such as currency, date format, metric vs. imperial system, etc. and taking into account cultural realities and sensibilities (religious or political taboos, gender norms, etc.).

  • Locale. A combination of a language and a regional variant. For example, English is a language, while English (UK) and English (AU) are locales. Locales that share a language don’t necessarily share the same conventions or cultural norms.

  • String. In programming, a string is a variable that contains text, as opposed to a number, a collection, etc. It can be as short as a single word or letter, or as long as multiple paragraphs.

  • Key. A translation key, in our context, is a combination of three things: a unique identifier, a locale and a localized string. Each time you add a row to the Translations section of the character sheet editor, you create a new key that you can refer to in your code. When the character sheet is rendered, if a translation exists for the user’s current locale for a given key, that translation will be displayed. Otherwise, the key’s identifier will be displayed instead.

Comparison table

Aspect
i18n()
$l10n

Number of plugins to maintain

One (downside: every time you add or update a locale, every user sees that your plugin was updated, even if it doesn’t concern them).

One per supported locale (downside: duplication of code, which creates more work when updating the core logic or appearance of your plugin).

Completeness of localization

Minimal: Only the content of the actual character sheet is localized; optionally, the plugin’s description can include several languages. Crucially, attribute names are fixed to the original language, which isn’t ideal for end users who don’t speak that language or don’t know the game system’s terminology in languages other than their own.

Complete, or near-complete if preferred: Each plugin can be named and described in its own language, including version/update notes, and you can use attribute names that are specific to that locale if you don’t mind the extra work of adapting them.

Ease of adding and maintaining strings

Cumbersome: You have to create one localization key per text string per language manually in the plugin editing form, and copy-paste each translation provided by your translators one key at a time.

Simple: Localization keys are part of the HTML field, so you only need to type additional lines, and you can prepare them in external software and copy-paste them into the Marketplace in one go.

Valid characters in keys

Parentheses are not allowed in either identifiers or translations because of the way Kanka validates the Blade syntax, but there exists a workaround. Quotation marks are allowed, but some care is needed. Ampersands are somewhat buggy. See Special characters in strings.

Ease of updating the sheet’s layout or logic

Easy: Your changes instantly apply to all languages.

Repetitive: After updating your core plugin, you need to copy-paste the new code to each variant (making sure not to overwrite the localized strings).

Ability to adapt to the syntax of each language

Possible by passing parameters to the method to include variables, which translators can then position properly in their respective version of a sentence.

Much the same, but with a simpler code syntax (no need to specify parameters to make variables available).

Locales supported

Only those offered by Kanka’s language switcher (though you may be able to get new ones added upon request). Note that the Translations form only suggests the languages you’ve set in the plugin’s description, so you should do that step first.

Any actual or fictional language you can think of. You can even provide an attribute for users to choose an a per-entity basis.

One thing to note, which could be seen as a benefit or a drawback, is that plugins localized with i18n() will switch their language based on each visitor’s current language setting. $l10n, on the other hand, is set to a specific language. Since attribute values are also monolingual anyway, I expect the latter to make more sense in the vast majority of campaigns. If you run a campaign in multiple languages, you would need to install different plugin versions, but in doing so you would still get the benefit of localized attribute names, if provided by the plugin maintainer.

A Third Way. Now that JavaScript is allowed in character sheets, there are various ways one could handle localization with it. The most obvious would be to have a collection of localization keys for each locale and choose which one to use according to the current interface language (i.e. the value of document.querySelector("html").lang). How you use them would depend on how you generate the sheet; you would also have the option of handling it with a single plugin, or distinct plugins like $l10n to have localized attribute names.

The $l10n approach

Here is a simple example of the $l10n approach, which adds a single block of code at the top of your plugin’s HTML field. It assumes an attribute called character_name is present, and displays two localized strings, each one a separate sentence. The code will be fully explained in the next section, but for now, just observe how we preset the text at the top in the $l10n variable, then display our strings in a separate section by referring to each key.

@for($l10n = [
"owner" => "This character sheet belongs to $character_name.",
"thanks" => "Thank you for coming."
]; 1 < 1;)
@endfor

{{ $l10n['owner'] }} {{ $l10n['thanks'] }}

Result: "This character sheet belongs to $character_name. Thank you for coming." (with $character_name showing the attribute’s value).

If I cloned the plugin to support French, I would then only need to change the localized strings; the rest of the code would be unchanged:

@for($l10n = [
"owner" => "Cette feuille de personnage appartient à $character_name.",
"thanks" => "Merci de votre visite."
]; 1 < 1;)
@endfor

{{ $l10n['owner'] }} {{ $l10n['thanks'] }}

Result: "Cette feuille de personnage appartient à $character_name. Merci de votre visite." (with $character_name showing the attribute’s value).

Technical explanation

This section takes a detailed look at each part of the code logic. Feel free to skip parts that seem too technical for you. At the end of the day, you can simply copy the example and change the keys and values as you need.

The first thing to note is that we define our $l10n variable in a @for block. Normally, Marketplace templates don’t allow you to create variables on the fly – you can only use the attributes that exist on the entity. However, the first parameter of a @for statement allows you to set variables. Typically this is used to create an iterator to help determine when to end the loop, but we can create as many other variables as we want there. We use this technique to create an array, specifically, so we can easily recognize every reference to $l10n in our code as a localized string, with the respective key following in brackets. That makes the display syntax fairly similar to the i18n() approach, except that we need to include the curly brackets to display our variables: @i18n('owner') versus {{ $l10n['owner'] }}.

Each key is defined on its own line, with the unique identifier on the left and the text value on the right. Don’t forget the comma after each key.

On line 2, our string includes a variable, namely the character_name attribute. This is all you need to do to show a variable in a string, compared to the i18n() method which would require you to write something like this:

@i18n("This character sheet belongs to :character_name.", ['character_name' => $character_name])

One last thing to pay attention to is the actual condition of our for loop. We only allow it to run while 1 < 1, in other words never. There are countless other ways to achieve the same effect, but essentially you just need to make sure you don’t execute a loop needlessly (or worse, indefinitely). Even if it runs 0 times, the declarations in the first parameter, where we set our $l10n array, are executed. The third parameter can be left empty since it won’t be executed anyway.

Outside the code: maintaining your plugin

As mentioned in the comparison table, having a distinct plugin for each language variant allows you to provide localized descriptions and update notes, and makes your plugin easier to find with its localized name. It also means you aren’t constrained to Kanka’s supported languages – you could have a Klingon or Esperanto variant if you like. Lastly, when you update one variant, people who use a different one don’t receive a useless update notice.

When it comes to implementing translations, especially when you have other people translating for you, all you need to do is provide them a text file with the keys, letting them know not to change the identifiers or mess with anything outside the double quotes:

"owner" => "This character sheet belongs to $character_name.",
"thanks" => "Thank you for coming."

Once you receive the translations, you only need to copy-paste them back into the @for() at the top of your plugin’s HTML.

Advanced uses and edge cases

Localizing attribute names

The example above uses $character_name in both language variants. This is easier for you to maintain, but much less convenient for end users filling in their attributes. There is a fairly easy way to make things convenient for everyone, though, with just a bit more work for you and your translators. Basically, instead of only localizing strings, you can localize attribute names like so:

@for($character_name = $nom_du_personnage,
$l10n = [
"owner" => "Cette feuille de personnage appartient à $character_name.",
"thanks" => "Merci de votre visite."
]; 1 < 1;)
@endfor

On the first line here, you are creating a variable called $character_name and setting it to the value of the existing attribute nom_du_personnage. Having done that, you can change the attribute names in your template to match the localized form so that they are more accessible to end users. And that’s it; no need to worry about changing the logic part of your code to fix all references to attributes. Although @liveAttribute() presents an important exception here: if you use it, you would have to ensure that you pass it the name of the actual attribute on the entity, e.g. @liveAttribute('nom_du_personnage').

When asking others for translations, in addition to your keys, send them a list of your "canonical" attribute names and ask them for translations that respect the limitations set on variable/attribute names.

Including new or updated variables in localized strings

Setting all of our localization keys at once at the top of our code makes it clean and convenient. However, more advanced character sheets may want to change the attribute values entered by users in different ways and for various reasons, from mere formatting to automated calculations. To use a simplistic example, perhaps you want to show the character’s name in a different color past a certain level, and since it appears in several places, you want to make that determination only once. In such cases, you may need to have some logic, then your localization block, then your actual layout:

@if($level > 9) $character_name = "<span style='color: gold'>$character_name</span>" @endif

@for($l10n = [
"owner" => "This character sheet belongs to $character_name.",
"thanks" => "Thank you for coming."
]; 1 < 1;)
@endfor

{!! $l10n['owner'] !!} {{ $l10n['thanks'] }}

In most cases, you should still be able to find a way to keep all of your localization strings in a single block, and you will only need to be careful not to overwrite it when copy-pasting updated code from your core plugin. If for any reason you need to change a localization string after its initial definition, you can use a similar trick to update a single key in a simpler @if() clause:

@if(some complex condition)
	@if($l10n['thanks'] = "Thank you for leaving.") @endif
@endif
{{ $l10n['thanks'] }}

This syntax, with a single equal sign, sets the variable’s value instead of comparing it. This is not the intended use of @if and does not work when creating arrays, but it serves us well for this. Thus, the above will change the value of $l10n['thanks'] from that point on, without affecting the rest of the localization array. We then show the value whether or not it was updated. Again, in most scenarios you could avoid this by simply using different localization keys when certain conditions are met, and this is just a trick to get you out of unusual edge cases.

Special characters in strings

When declaring strings, most examples use double quotes for familiarity and because they don’t cause issues with single quotes used as apostrophes. But there are other ways to enclose a string to make double quotes available inside them:

"quotes" => "This is a valid string 'definition'.",
"quotes" => 'This is a valid string "definition" (but beware of straight apostrophes).',
"quotes" => `This is a 'valid' string "definition".`,

To my knowledge, there are very few languages that make use of the backtick on its own, making it the safest to use in most cases.

As for parentheses, including them anywhere inside a @for() method will result in parts of your code being replaced with _invalid method_ as a safety measure. However, they are allowed in @if(), so you could declare additional keys after your main @for() block if necessary:

@for($l10n = [
"thanks" => "Thank you for coming."
]; 1 < 1;)
@endfor

@if ($l10n['owner'] = "This character sheet belongs to me ($character_name).") @endif

Just keep in mind that you can only declare one variable at a time using @if().

Lastly, ampersands work if you use doubled up HTML entity notation as &&amp;, but since the plugin editor will render that as && the next time you edit your plugin, they will break if you don’t update them with each modification of the plugin.

Conclusion

For me, i18n() is still too limited to use it for anything but small, simple plugins. Mainly because it only localizes one part of the experience of using character sheets, but also because I find it tedious to create and handle individual keys in a form, then copy-paste each translated string into it individually, instead of just typing them out in the code and copy-pasting in bulk. There’s no doubt that $l10n’s thoroughness comes at the price of extra work in other areas, so it’s up to you to pick what works best for you!

Last updated