Add translation API
Pathoschild opened this issue · 6 comments
Add a way for SMAPI mods to load custom translations for their own use, so every mod doesn't need to reimplement it themselves.
Stardew Valley's approach with XNBs is unnecessarily cumbersome, so we probably shouldn't use it. Maybe something like this?
Proposed approach A: i18n files
Overview
-
A mod can include an
i18n
folder with one file per language:LookupAnything/ i18n/ en.json es.json ja.json
-
Each file contains a flat key/value structure:
{ "field-birthday": "Birthday", "field-can-romance": "Can romance" }
-
The mod can call a simple method to get a translation for the current locale:
string label = helper.Translate("npc-field-birthday");
This method will perform locale fallback to find a matching translation (e.g.
pt-BR.json
→pt.json
→default.json
). If none is found, it will show some sort of placeholder like(translation:npc-field-birthday)
.
Pros & cons
- Benefits:
- Very simple for mods to implement and use.
- Players can add or edit their own translations.
- Players can submit translations with zero coding or XNB knowledge.
- Once translations are added to a mod, they're very discoverable for both players and developers.
- Disadvantages:
- When a mod doesn't have translations yet, the feature isn't discoverable. The modder needs to read the documentation to know about creating an
i18n
folder.
- When a mod doesn't have translations yet, the feature isn't discoverable. The modder needs to read the documentation to know about creating an
I think this would be a huge boon to the modding community, and would allow our new players from 1.2 to join. Of all the feature requests, I think this one is the most beneficial to SMAPI and SDV players.
The first prototype with this API is done:
/// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary>
public interface ITranslationHelper
{
/*********
** Public methods
*********/
/// <summary>Get all translations for the current locale.</summary>
IDictionary<string, string> GetTranslations();
/// <summary>Get a translation for the current locale.</summary>
/// <param name="key">The translation key.</param>
/// <param name="required">Whether to throw an exception if the translation key isn't found. If <c>false</c>, missing translations will return placeholder text.</param>
/// <exception cref="KeyNotFoundException">The <paramref name="key"/> doesn't match an available translation, and <paramref name="required"/> is <c>true</c>.</exception>
string Translate(string key, bool required = false);
/// <summary>Get a translation for the current locale.</summary>
/// <param name="key">The translation key.</param>
/// <param name="default">The default text to return if the translation isn't found.</param>
string Translate(string key, string @default);
}
and I added translations to Lookup Anything as a test case:
That said, I'm not happy with the usability. Tokens are inelegant for both modders and translators:
- For modders:
string.Format(helper.Translate("purchased-items"), amount, displayName);
- For translators:
You bought {0} {1}!
We could maybe rethink the API design to support named tokens out of the box. That would be easier for translators, and allow more complete translation support in the future (like pluralisation). For example:
- For modders:
helper.Translate("purchased-items", new { amount, displayName });
- For translators:
You bought {{amount}} {{displayName}}!
The disadvantages are that it's less clear for modders who aren't familiar with anonymous objects used as arguments, and SMAPI would probably need an extra dependency like dotLiquid, which may complicate support.
Open questions
How to handle missing translations
The API returns a placeholder string if a translation isn't found by default, to make it easier to find/report/fix problems:
Is that desirable? Should it default to null
or an exception instead?
How to support tokens
The current API lets you specify how to handle missing translations, but adding token support is awkward (params
arguments would conflict with the method overloads).
Possible approaches:
- Don't support tokens.
I think tokens are an important part of translations, so this isn't a good approach.// default (no tokens + placeholder if missing) string text = helper.Translate("purchased-items"); // tokens + exception if missing string text = string.Format(helper.Translate("purchased-items", required: true), amount, displayName); // tokens + null if missing string text = string.Format(helper.Translate("purchased-items", null), amount, displayName); // tokens + placeholder if missing string text = string.Format(helper.Translate("purchased-items"), amount, displayName);
- Split
Translate
into multiple methods with token support.
This works, but it's inelegant.// default (no tokens + placeholder if missing) string text = helper.TranslatePlaceholder("purchased-items"); // tokens + exception if missing string text = helper.TranslateRequired("purchased-items", amount, displayName); // tokens + null if missing string text = helper.TranslateOptional("purchased-items", amount, displayName) ?? "default text"; // tokens + placeholder if missing string text = helper.TranslatePlaceholder("purchased-items", amount, displayName);
- Create a fluent API for translation (with
ITranslation
interface).
This is an interesting approach, except for.ToString()
being required (since C# doesn't allow implicit conversion for interfaces).// default (no tokens + placeholder if missing) string text = helper.Translate("purchased-items").ToString(); // tokens + exception if missing string text = helper.Translate("purchased-items", required: true).Tokens(amount, displayName).ToString(); // tokens + null if missing string text = helper.Translate("purchased-items", null).Tokens(amount, displayName).ToString(); // tokens + placeholder if missing string text = helper.Translate("purchased-items").Tokens(amount, displayName).ToString();
- Create a fluent API for translation (with
Translation
class).
Using a concrete type means it can implicitly convert to string:// default (no tokens + placeholder if missing) string text = helper.Translate("purchased-items"); // tokens + exception if missing string text = helper.Translate("purchased-items", required: true).Tokens(amount, displayName); // tokens + null if missing string text = helper.Translate("purchased-items", null).Tokens(amount, displayName); // tokens + placeholder if missing string text = helper.Translate("purchased-items").Tokens(amount, displayName);
The fluent API is implemented in develop
, pending feedback. Example usage:
// read a simple translation
string label = helper.Translate("item-type.label");
// read a translation which uses tokens
string text = helper.Translate("item-type.fruit-tree").Tokens(new { fruitName = "apple" });