Pathoschild / SMAPI

The modding API for Stardew Valley.
https://smapi.io/
GNU Lesser General Public License v3.0
1.93k stars 267 forks source link

Add translation API #296

Closed Pathoschild closed 7 years ago

Pathoschild commented 7 years ago

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

Pathoschild commented 7 years ago

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

miketweaver commented 7 years ago

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.

Pathoschild commented 7 years ago

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:

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:

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.

Pathoschild commented 7 years ago

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);
Pathoschild commented 7 years ago

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

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