Closed Pathoschild closed 7 years ago
Stardew Valley's approach with XNBs is unnecessarily cumbersome, so we probably shouldn't use it. Maybe something like this?
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)
.
i18n
folder.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:
string.Format(helper.Translate("purchased-items"), amount, displayName);
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:
helper.Translate("purchased-items", new { amount, displayName });
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.
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?
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" });
Seems to be working fine with the mods translated so far. Closing now; we can reopen if something comes up before release.
Add a way for SMAPI mods to load custom translations for their own use, so every mod doesn't need to reimplement it themselves.