Tongue i18n Library

repository·master·Indexed 43 days ago

https://github.com/tonsky/tongue

A lightweight, DIY internationalization library for Clojure and ClojureScript. It uses Clojure maps for dictionaries and supports namespaced keys, positional and named substitutions, arbitrary function translations, and locale fallbacks. Includes utilities for localized number formatting via tongue/number-formatter and date formatting via tongue/inst-formatter.

Tokens
4K
Snippets
8
Records
9
Agent score
6%

What's inside Tongue

  1. Define i18n dictionaries

    master

    Tongue uses standard Clojure maps as dictionaries. Dictionaries support several features:

    • Simple keys: Direct mapping to strings.
    • Namespaced keys: Use keywords like :weather/rain.
    • Nested maps: Nested maps are automatically unpacked into namespaced keys (e.g., :animals {:dog "Dog"} becomes :animals/dog).
    • Substitutions: Use {1}, {2}, etc., for positional arguments, or use a map for named substitutions (e.g., {user "Tom"}).
    • Aliases: Map a key to another existing key to reuse strings.
    • Arbitrary functions: Use a function as a translation value. If the key resolves to a function, Tongue calls it with the provided arguments. This is useful for complex logic like pluralization.
    • Fallback Locales: Use the :tongue/fallback key to specify a default locale if a requested locale is missing.
    • Custom Missing Key Message: Override the default error message using the :tongue/missing-key key.
    (def dicts
      { :en { ;; simple keys
              :color "Color"
    
              ;; namespaced keys
              :weather/rain   "Rain"
    
              ;; nested maps unpacked into namespaced keys
              :animals { :dog "Dog"   ;; => :animals/dog
                         :cat "Cat" } ;; => :animals/cat
    
              ;; substitutions
              :welcome "Hello, {1}!"
              :between "Value must be between {1} and {2}"
    
              ;; For using a map
              :mail-title "{user}, {title} - Message received."
    
              ;; aliases
              :frontpage-greeting :welcome
              
              ;; arbitrary functions
              :count (fn [x]
                       (cond
                         (zero? x) "No items"
                         (= 1 x)   "1 item"
                         :else     "{1} items"))
    
              ;; optional -- override “Missing key” message
              :tongue/missing-key "Missing key {1}"
            }
    
        :en-GB { :color "colour" }
        :tongue/fallback :en })
  2. Install Tongue

    master

    Add Tongue as a dependency to your project.clj file.

    Production Configuration

    In production, add the following JVM option to disable Clojure spec assertion compilation: -Dclojure.spec.compile-asserts=false

    Development Configuration

    In development, add the following JVM option to enable Clojure spec assertion checking: -Dclojure.spec.check-asserts=true

    [tongue "0.4.4"]
  3. Use positional and named interpolation in strings

    master

    Tongue allows you to inject values into translation strings using two methods:

    1. Positional Interpolation: Use numeric placeholders like {1}, {2}, etc.
    2. Named Interpolation: Use keyword placeholders like {user} or {title}.

    When calling the translation function returned by tongue/build-translate, you can pass either a single value (for positional) or a map of values (for named interpolation).

    (require '[tongue.core :as tongue])
    
    (def dicts
      { :en { :welcome "Hello, {1}!"
              :mail-title "{user}, {title} - Message received."
            }})
    
    (def tr (tongue/build-translate dicts))
    
    ;; Positional usage
    (tr :en :welcome "Nikita") ;; => "Hello, Nikita!"
    
    ;; Named usage
    (tr :en :mail-title {:user "Tom" :title "New message"}) ;; => "Tom, New message - Message received."
  4. Localize numbers with number-formatter

    master

    You can create localized number formatters using tongue/number-formatter. This function takes a map of formatting rules (e.g., :group and :decimal).

    Automatic Number Substitution

    If you add a :tongue/format-number key to a locale's dictionary, any number passed as a substitution in that locale will be automatically formatted using that function.

    ;; Manual usage
    (def format-number-en
      (tongue/number-formatter { :group "," :decimal "." }))
    (format-number-en 9999.9) ;; => "9,999.9"
    
    ;; Automatic usage via dictionary
    (def dicts
      { :en { :tongue/format-number format-number-en
              :count "{1} items" }
        :ru { :tongue/format-number (tongue/number-formatter { :group " " :decimal "," })
              :count "{1} штук" }})
    
    (def translate (tongue/build-translate dicts))
    
    (translate :en :count 9999.9) ;; => "9,999.9 items"
    (translate :ru :count 9999.9) ;; => "9 999,9 штук"
  5. Localize dates with inst-formatter

    master

    To localize dates, use tongue/inst-formatter. This requires a dictionary of locale strings (e.g., months, weekdays, eras).

    Setup

    1. Define a map of locale strings (e.g., :months-short, :weekdays-long).
    2. Create the formatter: (tongue/inst-formatter "format-string" locale-strings).

    Arity and Timezones

    The resulting function accepts two arities:

    • [instant]: Assumes UTC in Clojure or browser timezone in ClojureScript.
    • [instant timezone]:
      • Clojure: java.util.TimeZone object.
      • ClojureScript: Integer GMT offset in minutes (e.g., 360 for GMT+6).

    Automatic Date Substitution

    Add a :tongue/format-inst key to a locale's dictionary to enable automatic formatting for datetime substitutions.

    ;; 1. Define locale strings
    (def inst-strings-en
      { :months-short ["Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec"]
        :dayperiods      ["AM" "PM"] 
        ;; ... other keys like :weekdays-short, :eras-short, etc.
      })
    
    ;; 2. Build formatter
    (def format-inst
      (tongue/inst-formatter "{month-short} {day}, {year} at {hour12}:{minutes-padded} {dayperiod}" inst-strings-en))
    
    ;; 3. Use it
    (format-inst #inst "2016-07-11T22:31:00+06:00")
    
    ;; 4. Automatic substitution via dictionary
    (def dicts
      { :en { :tongue/format-inst (tongue/inst-formatter "{month-short} {day}, {year}" inst-strings-en)
              :published "Published at {1}" }
       })
    
    (def translate (tongue/build-translate dicts))
    (translate :en :published #inst "2016-01-01") ;; => "Published at Jan 1, 2016"
  6. Implement the IInterpolate interface for custom types

    master

    If your translation dictionary contains non-string types (like vectors) that need to be interpolated, you must implement the tongue.core/IInterpolate interface for that type.

    This interface requires two methods:

    1. interpolate-named: Handles named interpolation logic.
    2. interpolate-positional: Handles positional interpolation logic.

    Once implemented, you can use special markers within your data structures (like :arg/key or [:arg index]) to trigger substitution during translation.

    (require '[tongue.core :as tongue])
    
    ;; Example: Implementing IInterpolate for PersistentVector to support custom interpolation markers
    (extend-type clojure.lang.PersistentVector
      tongue/IInterpolate
      (interpolate-named [v dicts locale interpolations]
        (mapv (fn [x]
                (if (and (keyword? x)
                         (= "arg" (namespace x)))
                  (get interpolations x)
                  x)) v))
    
      (interpolate-positional [v dicts locale interpolations]
        (mapv (fn [x]
                (if (and (vector? x)
                         (= :arg (first x)))
                  (nth interpolations (second x))
                  x)) v)))
    
    ;; Usage with interpolated vectors:
    (def dicts
      { :en { :welcome [:div {} "Hello, " [:arg 0]]
              :mail-title [:arg/user ", Message received."]
            }})
    
    (def tr (tongue/build-translate dicts))
    
    (tr :en :welcome "Nikita")
    ;; => [:div {} "Hello, " "Nikita"]
    
    (tr :en :mail-title {:arg/user "Tom"})
    ;; => ["Tom" ", Message received."]
  7. Build and use the translation function

    master

    To use your dictionaries, pass them to tongue/build-translate. This returns a function with the signature [locale key & args] => string.

    Key Behaviors

    • Namespaced Keys: Resolves keys like :animals/dog from nested structures.
    • Substitutions: Supports positional arguments (:welcome "Nikita") and maps for named arguments (:mail-title {:user "Tom"}).
    • Function Translations: If a key maps to a function, it is executed with the provided arguments.
    • Locale Fallback:
      • Multi-tag locales (e.g., :zh-Hans-CN) fall back through increasingly generic versions (:zh-Hans -> :zh) before using the :tongue/fallback locale.
      • If a locale or key is missing, the fallback locale is used.
    • Missing Keys: If no translation is found, it returns a default error string (e.g., "|Missing key :unknown|").
    (require '[tongue.core :as tongue])
    
    (def translate
      (tongue/build-translate dicts))
    
    ;; Usage examples
    (translate :en :color)                ;; => "Color"
    (translate :en :animals/dog)         ;; => "Dog"
    (translate :en :welcome "Nikita")   ;; => "Hello, Nikita!"
    (translate :en :count 0)             ;; => "No items" (via function)
    (translate :en-GB :color)           ;; => "colour"
  8. Reference: inst-formatter tokens

    master

    The following tokens are available for use within tongue/inst-formatter strings. Anything not enclosed in {} is printed as-is.

    | Code | Example | Meaning |
    | -------------------- | ----------------------- | -------------------- |
    | `{hour24-padded}` | 00, 09, 12, 23 | Hour of day (00-23), 0-padded |
    | `{hour24}` | 0, 9, 12, 23 | Hour of day (0-23) |
    | `{hour12-padded}` | 12, 09, 12, 11 | Hour of day (01-12), 0-padded |
    | `{hour12}` | 12, 9, 12, 11 | Hour of day (1-12) |
    | `{dayperiod}` | AM, PM | AM/PM from `:dayperiods` |
    | `{minutes-padded}` | 00, 30, 59 | Minutes (00-59), 0-padded |
    | `{minutes}` | 0, 30, 59 | Minutes (0-59) |
    | `{seconds-padded}` | 00, 30, 59 | Seconds (00-60), 0-padded |
    | `{seconds}` | 0, 30, 59 | Seconds (00-60) |
    | `{milliseconds}` | 000, 123, 999 | Milliseconds (000-999), always 0-padded |
    | `{weekday-long}` | Wednesday | Weekday from `:weekdays-long` |
    | `{weekday-short}` | Wed, Thu | Weekday from `:weekdays-short` |
    | `{weekday-narrow}` | W, T | Weekday from `:weekdays-narrow` |
    | `{weekday-numeric}` | 1, 4, 5, 7 | Weekday number (1-7, Sunday = 1) |
    | `{day-padded}` | 01, 15, 29 | Day of month (01-31), 0-padded |
    | `{day}` | 1, 15, 29 | Day of month (01-31) |
    | `{month-long}` | January | Month from `:months-long` |
    | `{month-short}` | Jan, Feb, Mar | Month from `:months-short` |
    | `{month-narrow}` | J, F, M | Month from `:months-narrow` |
    | `{month-numeric-padded}` | 01, 02, 12 | Month number (01-12, January = 01), 0-padded |
    | `{month-numeric}` | 1, 2, 12 | Month number (1-12, January = 1) |
    | `{year}` | 1999, 2016 | Full year (0-9999) |
    | `{year-2digit}` | 99, 16 | Last two digits of a year (00-99) |
    | `{era-long}` | Anno Domini | Era from `:eras-long` |
    | `{era-short}` | BC, AD | Era from `:eras-short` |