Skip to main content

locale-kit

A small library to handle translations and localisations. It may be rough around the edges for now, but suits my needs (and hopefully yours) decently enough.

NPM Build and Publish

Support

For support, feel free to open an issue!

Installation

Install locale-kit by adding the import and initializing the class with your translation/language config.

import { LocaleKit } from "https://deno.land/x/localekit/mod.ts";

Usage/Examples

Example main.ts file

import { LocaleKit } from "https://deno.land/x/localekit/mod.ts";
import locale_config from "./locale.config.ts";

const locale = new LocaleKit({
  languages: locale_config,
  fallback_language: "en",
});

console.log(locale.t("common.languages.en", { lang: "en" })); // Will output "English"
console.log(locale.t("common.languages.es", { lang: "en" })); // Will output "Spanish"
console.log(locale.t("common.languages.en", { lang: "es" })); // Will output "Inglés"
console.log(locale.t("common.languages.es", { lang: "es" })); // Will output "Español"
console.log(locale.t("common.languages.en")); // Will output "English" as the fallback language is set to "en"

Example locale.config.ts file

export default {
  en: {
    common: {
      languages: {
        en: "English",
        es: "Spanish
      }
    }
  },
  es: {
    common: {
      languages: {
        en: "Inglés",
        es: "Español"
      }
    }
  }
};

Example with formatting and data replacement

What translation tool would be feature-complete(ish) without a formatter and data inserter? The general formatting of a dynamic translation key follows the following:

  • starts with [[~
  • ends with ]]
  • first parameter is a key on the params object (can be as deeply nested as you want) and must be wrapped in curly braces; examples:
    • {a_key_on_the_root_object} - root level key
    • {parent.child_key} - Nested key
    • {key.deeply.nested.0.and_in_array} - Nested deeply and within an array
    • {key.still.2.1.3.nested.deeply.} - Nested deeply within multi-dimensional arrays
  • next parameter onwards is a format option key. The key can be a string but can not include spaces but only supports the regex values \w-; examples:
    • valid: test_case
    • valid: test-case
    • valid: testCase
    • invalid: test case
    • invalid: test(case)
    • invalid: test.case
  • key followed by colon, and value of test case is wrapped in backticks (or with ;: and :; at the start and end if you prefer your templates/dynamic structures to be able to span multiple lines without having to escape the backtics in javascript)
  • each parameter should be separated by a lone pipe character
  • optionally, a default case can be passed in at the end to handle edge cases
  • optionally, you can embed a value in a format case using double squigly lines; examples:
    • test_case: `Here’s my embeded data {{data_key}}“
  • optionally, embedded values can have fallback strings spanning multiple lines. They should be surrounded by double pipes, and can’t contain double pipes; examples:
    • {{key}}||oops, nothing here||

If we put all that together and format it to our liking, we get something like the following: (new lines should be treated as spaces)

[[~
    {key}
     test_case_1: `test 2` |
     test_case_2: `{{key}} {{key2}}||no key found||` |
     default: `oops, no cases matched`
]]

The above would only look pretty outside of a JSON file though. The return characters are purely decorative outside of the case formats. Additionally, you can replace the backticks in the case values with ;: and :; at the start and end if you prefer your templates/dynamic structures to be able to span multiple lines without having to escape the backtics in javascript. An example of this would be:

const str = `Lorem Ipsum [[~
    {key}
   test_case_1: ;:test 2:; |
   test_case_2: ;:{{key}} {{key2}}||no key found||:; |
   default: ;:oops, no cases matched:;
]]`
// ----- compared to ----- //
const str = `Lorem Ipsum [[~
    {key}
   test_case_1: \`test 2\` |
   test_case_2: \`{{key}} {{key2}}||no key found||\` |
   default: \`oops, no cases matched\`;
]]`

The regex that handles the parsing of the above can be found in /mod.ts. Here’s the regex to save you the trouble though:

const DYN_STR_REGEX =
  /\[\[~\s*(?:{(?<data_key>.*?)})\s*(?<cases>(?:\s*(?<case_key>(?:(?:[\w-])|(?:N?GTE?|N?LTE?|N?EQ|AND|N?BT|N?IN|X?OR)\((?:[^)]+)\))+)\s*:\s*(?:(?:`[^`]*`)|(?:;:(?:(?!:;).)*:;))\s*\|*\s*)+)+\]\]/gs;

Here’s an example properly showing how this would be used in the real-world:

// /locale.config.ts
...    
        common: {
            counter: 'You have [[~ {count} 0: `no apples` | 1: `one apple` | GTE(25): `so many ({{count}}) apples` default: `{{count}} apples` ]]'
        }
...
// --------------------------- //

// /mod.ts
import { LocaleKit } from "https://deno.land/x/localekit/mod.ts";
import locale_config from "./locale.config.ts";

const locale = new LocaleKit({
  languages: locale_config,
  fallback_language: "en",
});

console.log(locale.t("common.counter", { count: 0  })); // Will output "You have no apples"
console.log(locale.t("common.counter", { count: 1  })); // Will output "You have one apple"
console.log(locale.t("common.counter", { count: 10 })); // Will output "You have so 10 apples"
console.log(locale.t("common.counter", { count: 27 })); // Will output "You have so many (27) apples"

Example with formatting and data replacement, as well as data fallback

If a value doesn’t exist for the provided key, you can provide a fallback value. This can be anything but must not contain the sequence || and must begin and end with ||

// /locale.config.ts
...    
        common: {
            counter: 'You have [[~ {count} 0: `no apples` | 1: `one apple` | default: `{{count}}||inappropriate|| apples` ]]'
        }
...
// --------------------------- //

// /mod.ts
import { LocaleKit } from "https://deno.land/x/localekit/mod.ts";
import locale_config from "./locale.config.ts";

const locale = new LocaleKit({
  languages: locale_config,
  fallback_language: "en",
});

console.log(locale.t("common.counter", { count: 0  })); // Will output "You have no apples"
console.log(locale.t("common.counter", { count: 1  })); // Will output "You have one apple"
console.log(locale.t("common.counter", { other_key: 10 })); // Will output "You have inappropriate apples" as the key `count` doesn't exist

Function case keys for further filtering

We’ll start off with a direct example from the tests file:

const svc = new LanguageService();

locale.addLanguage("en", {
  common: {
    test_age: "You are [[~ {age} LTE(num:12): `a child` | BT(num:12, num:18): `a teenager` | GTE(num:18): `an adult` ]]"
  },
});

assertEquals(svc.t("common.test_age", { age: 18 }), "You are an adult");
assertEquals(svc.t("common.test_age", { age: 13 }), "You are a teenager");
assertEquals(svc.t("common.test_age", { age: 8 }), "You are a child");

Each sequentual assertion would provide the valid answers. The functions get run in sequential order, exiting once one returns true.

There’s 17 comparison functions in total, most are just variants of each other though:

[
  "GT", "GTE", "NGT", "NGTE", // greater than functions
  "LT", "LTE", "NLT", "NLTE", // less than functions
  "EQ", "NEQ",  // equality functions (strict === and !==)
  "AND", // checking for two boolean values
  "BT", "NBT", // between two numbers (non-inclusive 15 is not between 10-15)
  "IN", "NIN", // array functions, checks to see if the current variable is inside a provided options key array
  "OR", "XOR" // standard or functions (a || b, a !== b)
]

So a readout of all of those would be:

  • greater than
  • greater than or equal to
  • not greater than
  • not greater than or equal to
  • less than
  • less than or equal to
  • not less than
  • not less than or equal to
  • equal to (strict)
  • not equal to (strict)
  • and (&&)
  • between (GT(a, b) && LT(a, c))
  • not between
  • in (string or array .includes)
  • not in
  • or (||)
  • xor (a !== b, strict)

The first parameter (which we’ll call a) is always passed in by the dynamic replacer as the parameter at the start of the statement

[[~
    {key.child.child} <---- this
    ...
]]

The second parameter (and third if it requires one) are ones passed in by you (b, and c respectively) Each parameter is prefixed by its type to aid the parser. The prefixes available are:

  • num - a simple number type, parsed as either an int or a float depending on if a period is detected. The answer is thrown out if Number.isNaN returns true.
  • str - A string
  • bool - a boolean value. this can be written either as: bool:1, or bool:true (and their false counterparts)
  • key - a value that should be fetched from the options object you passed in. This is handled the same way as the afformentioned parameter a at the start of the statement

Spacing doesn’t matter when writing the function, it can be formatted in a number of ways to help with readability. eg:

  • GT(num:1) test
  • GT( num: 1 ) test
  • GT(num : 1) test Or even:
GT(
    num: 1
)

Each function has a list of available types to use for each parameter, these apply to afformentioned parameters b, and c.

fn group param: b param: c
GT, GTE, NGT, NGTE num, str, key N/A
LT, LTE, NLT, NLTE num, str, key N/A
EQ, NEQ num, str, key, bool N/A
BT, NBT num, str, key num, str, key
AND, OR, XOR num, str, key, bool N/A
IN, NIN key N/A

You’ll have to be careful when passing in user data as the statement starting parameter as this isn’t checked like the above.

On top of casting the type before the value, the different types also have their values wrapped differently:

  • strings: str: `test` (backtick wrapped string. you can put anything inside other than backticks)
  • numbers: num: 1 || num: 1.1
  • booleans: bool: 1 || bool: 0 || bool: false || bool: true
  • values of keys: key: {key1.child_key}

Parameters should be separated by a comma - but again, spacing doesn’t matter here.

An example with two given parameters can be found in the test Translate a key and handle function calls. Here’s the test separated out from the other one in there though:

Deno.test(
  { name: "Translate a key and handle function calls" },
  () => {
    const svc = new LocaleKit();

    svc.addLanguage("en", {
      common: {
        test_array: "You have [[~ {messages.length} EQ(num:0): `no` | LTE(num:3): `some` | LTE(num:8): `a few` | LTE(num:40): `a lot of` | GTE(num:41): `too many` | default: `{{messages.length}}` ]] messages",
      },
    });

    const arr = new Array(0);

    assertEquals(svc.t("common.test_array", { messages: arr }), "You have no messages");
    arr.length = 3
    assertEquals(svc.t("common.test_array", { messages: arr }), "You have some messages");
    arr.length = 8
    assertEquals(svc.t("common.test_array", { messages: arr }), "You have a few messages");
    arr.length = 40
    assertEquals(svc.t("common.test_array", { messages: arr }), "You have a lot of messages");
    arr.length = 41
    assertEquals(svc.t("common.test_array", { messages: arr }), "You have too many messages");
  }
);

Authors

License

MIT