SMAPI - Stardew Modding API

SMAPI - Stardew Modding API

971k Downloads

Add translation API

Pathoschild opened this issue · 6 comments

commented

Add a way for SMAPI mods to load custom translations for their own use, so every mod doesn't need to reimplement it themselves.

commented

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

  1. A mod can include an i18n folder with one file per language:

    LookupAnything/
       i18n/
          en.json
          es.json
          ja.json
    
  2. Each file contains a flat key/value structure:

    {
       "field-birthday": "Birthday",
       "field-can-romance": "Can romance"
    }
  3. 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.jsonpt.jsondefault.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.
commented

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.

commented

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> &lt; <c>pt.json</c> &lt; <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:

image

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.

commented

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:

  1. 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);
  2. 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);
  3. 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();
  4. 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);
commented

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" });
commented

Seems to be working fine with the mods translated so far. Closing now; we can reopen if something comes up before release.