Skip to content

Latest commit

 

History

History
759 lines (498 loc) · 29.6 KB

README.md

File metadata and controls

759 lines (498 loc) · 29.6 KB

🌍 typesafe-i18n

An opinionated, fully type-safe, lightweight localization library for TypeScript projects with no external dependencies.

npm version types included bundlse size bump version & publish to npm

Advantages

🐤 lightweight
👌 easy to use syntax
🏃 fast and efficient
💬 supports plural rules
🔀 allows formatting of values e.g. locale-dependent date or number formats
💪 can be used for frontend, backend and API projects
🦺 prevents you from making mistakes
⛔ no external dependencies

Table of Contents

Installation

$ npm install --save typesafe-i18n

Usage

The package can be used inside JavaScript and TypeScript applications, but you will get a lot of benefits using it together with TypeScript, since the watcher will generate a few wrappers for easier usage.

You can use typesafe-i18n in a variety of project-setups:

General

The typesafe-i18n package exports a few different objects you can use to localize your applications:

In order to get full typechecking support, you sould use the exported functions in i18n-utils.ts generated by the watcher. It contains fully typed wrappers for the following core functionalities.

i18nString

The i18nString contains the core of the localization engine. To initialize it, you need to pass your desired locale and the formatters (optional) you want to use.
You will get an object back that can be used to transform your translation strings.

import { i18nString } from 'typesafe-i18n'

const locale = 'en'
const formatters = {
   uppercase: (value) => value.toUpperCase()
}

const LLL = i18nString(locale, formatters)

LLL('Hello {name|uppercase}!', { name: 'world' }) // => 'Hello WORLD!'

i18nObject

The i18nObject wraps your translations for a certain locale. To initialize it, you need to pass your desired locale, your translations-object and the formatters (optional) you want to use.
You will get an object back that can be used to access and apply your translations.

import { i18nObject } from 'typesafe-i18n'

const locale = 'en'
const translations = {
   HI: "Hello {name}!",
   RESET_PASSWORD: "reset password"
   /* ... */
}
const formatters = { /* ... */ }

const LL = i18nObject(locale, translations, formatters)

LL.HI({ name: 'world' }) // => 'Hello world!'
LL.RESET_PASSWORD() // => 'reset password'

i18n

Wrap all your locales with i18n. To initialize it, you need to pass a callback to get the translations-object for a given locale and a callback to initialize the formatters you want to use (optional).
You will get an object back that can be used to access all your locales and apply your translations.

import { i18n } from 'typesafe-i18n'

const localeTranslations = {
   en: { TODAY: "Today is {date|weekday}" },
   de: { TODAY: "Heute ist {date|weekday}" },
   it: { TODAY: "Oggi è {date|weekday}" },
}

const loadLocale = (locale) => localeTranslations[locale]

const initFormatters = (locale) => {
   const dateFormatter = new Intl.DateTimeFormat(locale, { weekday: 'long' })

   return {
      weekday: (value) => dateFormatter.format(value)
   }
}

const L = i18n(loadLocale, initFormatters)

const now = new Date()

L.en.TODAY({ date: now }) // => 'Today is friday'
L.de.TODAY({ date: now }) // => 'Heute ist Freitag'
L.it.TODAY({ date: now }) // => 'Oggi è venerdì'

A good usecase for this object could be inside your API, when your locale is dynamic e.g. derived from a users session:

function doSomething(session) {

   /* ... */

   const locale = session.language
   return L[locale].SUCCESS_MESSAGE()
}

Other frameworks

All you need is inside the generated file i18n-utils.ts. You can use the functions in there to create a small wrapper for your application.\

Feel free to open an issue, if you need a guide for a specific framework.

Typesafety

The typesafe-i18n package allows us to be 100% typesafe for our tranlsation functions and even the translations for other locales itself. It generates TypeScript definitions based on your base locale. Here you can see some examples where the generated types can help you:

typesafe auto-completion for all your defined locales

typesafe locales completion

typesafe auto-completion for all available translations

typesafe translation key completion

you will get an error if you forget to pass arguments

typesafe number of arguments

you will get an error if you pass the wrong type arguments

typesafe arguments 1 typesafe arguments 2

you will get an error if you forgot to add a translation in a locale

typesafe keys in translations

you will get an error when a translation is missing an argument

typesafe arguments in translation

In order to get get full typesafety for your locales, you can start the watcher during development. The watcher listens for changes you make to your base locale file and generates the corresponding TypeScript types.

Make sure you have installed node version > 12.x and are using a typescript version > 3.x.x.

The watcher will generate a different output depending on your TypeScript version. Older versions don't support all the features typesafe-i18n need to provide you with the best types. Make sure to use a TypeScript version > 4.1.x to benefit from all the typechecking features.

You can choose between two variants to run the watcher:

rollup-plugin

If you are already using rollup as your bundler, you can add the typesafeI18n-Plugin to your rollup.config.js.

import typesafeI18n from 'typesafe-i18n/rollup/rollup-plugin-typesafe-i18n'

export default {
   input: ...,
   output: ...,
   plugins: {
		...
      typescript(),

      // looks for changes in your base locale file in development mode & optimizes code in production mode
      typesafeI18n({ /* options go here */ })
   }
}

You can pass options to the watcher by creating a .typesafe-i18n.json file in the root of your workspace, or by passing it as an argument to the typesafeI18n plugin.

The rollup plugin has an advantage over the node-process, since it can also be used to optimize the translations.

Currently implemented optimizations:

  • get rid of the arguments type informations inside your base-translation:
    These types inside your base translations e.g. Hello {name:string}! are only used from the watcher to generate types for your translation. The rollup plugin removes these types from the translations in order to reduce bundle size by a few bytes. The example above will be optimized to Hello {name}! inside your production bundle.
  • include only certain locales:
    If you want to create an own bundle per locale. When running rollup to create your production-bundle, you can specify the 'locales' option to include only certain locales. The rollup plugin will remove all other locales from the production bundle.

More optimizations will follow.

webpack-plugin

If you are already using webpack as your bundler, you can add the TypesafeI18nPlugin to your webpack.config.js.

const TypesafeI18nPlugin = require('typesafe-i18n/webpack/webpack-plugin-typesafe-i18n').default

module.exports = {
   entry: ...,
   module: ...,
   output: ...,
   plugins: [
      ...
      // looks for changes in your base locale file in development mode
      new TypesafeI18nPlugin({ /* options go here */ })
   ],
}

You can pass options to the watcher by creating a .typesafe-i18n.json file in the root of your workspace, or by passing it as an argument to the constructor of TypesafeI18nPlugin.

node-process

This is the fallback option for all developers who aren't using rollup or webpack. Use this option if you bundle your application via parcel, esbuild etc. or if you don't use a bundler at all.

Start the watcher node process in your terminal:

> node ./node_modules/typesafe-i18n/node/watcher.js

Passing options to the watcher is possible by creating a .typesafe-i18n.json file in the root of your workspace.

You could use a npm-package like npm-run-all in order to start the watcher and you development-server in parallel.

Take a look at this demo repository to see how to run the watcher node process.

folder structure

This project requires you to use an opinionated folder structure for your locales. All your localization files are located inside the src/i18n folder.

When running the watcher for the first time, a few files will be generated:

src/
   i18n/
      en/
         index.ts
      custom-types.ts
      formatters.ts
      i18n-types.ts
      i18n-util.ts

Some files are auto-generated on every change of your base locale file; please don't make manual changes to them, since they will be overwritten.

  • en/index.ts
    If 'en' is your base locale, the file src/i18n/en/index.ts will contain your translations. Whenever you make changes to this file, the watcher will generate updated type definitions.

  • custom-types.ts
    To defining types that are unknown to typesafe-i18n.

  • formatters.ts
    In this file, you can configure the formatters to use inside your translations.

  • i18n-types.ts
    Type definitions are generated in this file. You don't have to understand them. They are just here to help TypeScript understand, how you need to call the translation functions.

  • i18n-util.ts
    This file contains wrappers with type-informations around the base i18n functions.

locales

Locales must follow a specific file pattern. For each locale, you have to create a folder with the name of the locale inside your src/i18n folder e.g. 'en', 'en-us', 'en-GB'. The name of the folder is also the name of the locale you use inside your code. Each locales folder needs to have an index.ts file with a default export. The file should export an object with string key-values pairs and should look something like:

import type { Translation } from '../i18n-types';

const de: Translation = {

   /* your translations go here */

}

export default de

make sure to give it the type of Translation to get compile-errors, when some translations are missing

custom types

If you want to pass arguments with your own types to the translation function, you need to tell typesafe-i18n how these types look like. In order to do this, you need to create an export with the exact name of that type inside this file.

If you have a translation with e.g. the type Sum,

const translations: BaseTranslation = {
   RESULT: 'The result is: {0:Sum|calculate}'
}

you need to export Sum as a type in your custom-types.ts file

export type Sum = {
   n1: number
   n2: number
   n2: number
}

Options

You can set options for the watcher in order to get optimized output for your specific project. The available options are:

key type default value
baseLocale string 'en'
locales string[] []
loadLocalesAsync boolean true
adapter 'node' | 'svelte' | 'react' | undefined undefined
outputPath string './src/i18n/'
typesFileName string 'i18n-types'
utilFileName string 'i18n-util'
formattersTemplateFileName string 'formatters'
typesTemplateFileName string 'custom-types'
adapterFileName string | undefined undefined
tempPath string './node_modules/typesafe-i18n/temp-output/'

baseLocale

Defines which locale to use for the types generation. You can find more information on how to structure your locales here.

locales

Specifies the locales you want to use. This can be useful if you want to create an own bundle for each locale. If you want to include only certain locales, you need to set the locales you want to use. If empty, all locales will be used.

Note: requires the usage of the rollup-plugin

loadLocalesAsync

Whether to generate code that loads the locales asynchronously. If set to true, a locale will be loaded, when you first access it. If set to false all locales will be loaded when you init the i18n-functions.

adapter

If this config is set, code will be generated that wraps i18n functions into useful helpers for that environment e.g. a svelte-store.

outputPath

Folder in which the files should be generated and where your locale files are located.

typesFileName

Name for the file where the types for your locales are generated.

utilFileName

Name for the file where the typesafe i18n-functions are generated.

formattersTemplateFileName

Name for the file where you can configure your formatters.

typesTemplateFileName

Name for the file where you can configure your custom-types.

adapterFileName

Name for the file when generating output for an adapter. The default filename is i18n-[adapter].

tempPath

Folder where the watcher can store temporary files. These files are generated when your base locale is analyzed and the types are generated. The folder will be cleared, after the types were generated. So make sure you use an empty folder, if you change this option.

Syntax

For more information about the LLL object, read the usage section.

passing arguments:

Syntax: {index}

const APPLES = '{0} apples'

LLL(APPLES, 12) // => '12 apples'

passing multiple arguments:

const FRUITS = '{0} apples and {1} bananas'

LLL(FRUITS, 3, 7) // => '3 apples and 7 bananas'

passing keyed arguments:

Syntax: {key}

const FRUITS = '{nrOfApples} apples and {nrOfBananas} bananas'

LLL(FRUITS, { nrOfApples: 3, nrOfBananas: 7 }) // => '3 apples and 7 bananas'

plural:

Syntax: {{singular|plural}}

const APPLES = '{nrOfApples} {{apple|apples}}'

LLL(APPLES, { nrOfApples: 1 }) // => '1 apple'
LLL(APPLES, { nrOfApples: 2 }) // => '2 apples'

plural (shorthand):

Syntax: {{plural}}

const APPLES = '{nrOfApples} apple{{s}}'

LLL(APPLES, { nrOfApples: 0 }) // => '0 apples'
LLL(APPLES, { nrOfApples: 1 }) // => '1 apple'
LLL(APPLES, { nrOfApples: 5 }) // => '5 apples'

plural (shorthand for only singular version):

Syntax: {{singular|}}

const MEMBERS = '{0} weitere{{s|}} Mitglied{{er}}'

LLL(MEMBERS, 0) // => '0 weitere Mitglieder'
LLL(MEMBERS, 1) // => '1 weiteres Mitglied'
LLL(MEMBERS, 9) // => '9 weitere Mitglieder'

plural (full syntax):

Under the hood, typesafe-i18n uses the Intl.PluralRules for detecting the plural form.

Syntax: {{zero|one|two|few|many|other}}

// locale set to 'ar-EG'

const PLURAL = 'I have {{zero|one|two|a few|many|a lot}} apple{{s}}'

LLL(PLURAL, 0) // => 'I have zero apples'
LLL(PLURAL, 1) // => 'I have one apple'
LLL(PLURAL, 2) // => 'I have two apples'
LLL(PLURAL, 6) // => 'I have a few apples'
LLL(PLURAL, 18) // => 'I have many apples'

format passed in arguments:

Read the formatters section to learn how you can configure formatters.

const now = Date.now()

LLL('Today is {date|weekday}', { date: now }) // => 'Today is Friday'
LLL('Heute ist {date|weekday}', { date: now }) // => 'Heute ist Freitag'

Allows also to format values by multiple formatters in row. The formatters will be called from left to right.

const now = Date.now()

LLL('Today is {date|weekday}', { date: now }) // => 'Today is Friday'
LLL('Today is {date|weekday|uppercase}', { date: now }) // => 'Today is FRIDAY'
LLL('Today is {date|weekday|uppercase|shorten}', { date: now }) // => 'Today is FRI'

typesafe nr of arguments:

For information about the LL object, read the usage section.

const translation = {
   HI: 'Hello {0}'
}

LL.HI() // => ERROR: Expected 1 arguments, but got 0.
LL.HI('John', 'Jane') // => ERROR: Expected 1 arguments, but got 2.
LL.HI('John') // => 'Hi John'

typesafe arguments:

Syntax: {key:type}

const translation = {
   HI: 'Hello {name:string}'
}

LL.HI('John') // => ERROR: Argument of type 'string' is not assignable to parameter of type '{ name: string; }'.
LL.HI({ name: 'John' }) // => 'Hi John'

everything together:

const MESSAGE = 'Hi {name:string|uppercase}, I want to buy {nrOfApples:number} apple{{s}}'

LLL(MESSAGE, { name: 'John', nrOfApples: 42 }) // => 'Hi JOHN, I would like to buy 42 apples'

text only:

Of course typesafe-i18n can handle that as well.

LLL('Welcome to my site') // => 'Welcome to my site'

Or if you are using the i18nObject (LL):

<script>
   const translation = {
      LOGIN: 'login'
   }
<script>

<div>
   {LL.LOGIN()} <!-- => 'login' -->

   <!-- NOTE: *not* supported everywhere -->
   {LL.LOGIN} <!-- => 'login' -->
</div>

Some frameworks like 'Svelte' will call the toString() method on objects inside the html-template. So if you have translations with no arguments you can omit calling the translation-function. The framework will do that for you.

formatters

You can specify your own formatters, that take an argument as an input and returns another value.

const formatters = {
   roiCalculator: (value) => {
      return (value * 4.2) - 7
   }
}

LLL('Invest ${0} and get ${0|roiCalculator} in return', 100)
// => 'Invest $100 and get $413 in return'

You can also use a few builtin formatters:

  • date
    A wrapper for Intl.DateTimeFormat
    import { date } from 'typesafe-i18n/formatters'
    
    const formatters = {
       weekday: date({ weekday: 'long' })
    }
    
    LLL('Today is {0|weekday}', new Date()) // => 'Today is friday'
  • time
    A wrapper for Intl.DateTimeFormat
    import { time } from 'typesafe-i18n/formatters'
    
    const formatters = {
       timeShort: time('en', { timeStyle: 'short' })
    }
    
    LLL('Next meeting: {0|timeShort}', meetingTime) // => 'Next meeting: 8:00 AM'
  • number
    A wrapper for Intl.NumberFormat
    import { number } from 'tyoesafe-i18n/formatters'
    
    const formatters = {
       currency: number('en', { style: 'currency', currency: 'EUR' })
    }
    
    LLL('your balance is {0|currency}', 12345) // => 'your balance is €12,345.00'
  • replace A wrapper for String.prototype.replace
    import { replace } from 'typesafe-i18n/formatters'
    
    const formatters = {
       noSpaces: replace(' ', '-')
    }
    
    LLL('The link is: https://www.xyz.com/{0|noSpaces}', 'super cool product')
    // => 'The link is: https://www.xyz.com/super-cool-product'
  • uppercase
    A wrapper for String.prototype.toUpperCase
    import { uppercase } from 'typesafe-i18n/formatters'
    
    const formatters = {
       upper: uppercase
    }
    
    LLL('I sayed: {0|upper}', 'hello') // => 'I sayed: HELLO'
  • lowercase
    A wrapper for String.prototype.toLowerCase
    import { lowercase } from 'typesafe-i18n/formatters'
    
    const formatters = {
       lower: lowercase
    }
    
    LLL('He sayed: {0|lower}', 'SOMETHING') // => 'He sayed: something'

Sizes

The footprint of the typesafe-i18n package is smaller compared to other existing i18n packages. Most of the magic happens in development mode, where the watcher generates TypeScript definitions for your translations. This means, you don't have to ship the whole package to your users. The only two parts, that are needed in production are:

  • string-parser: detects variables, formatters and plural-rules in your localized strings
  • translation function: injects arguments, formattes them and finds the correct plural form for the given arguments

These parts are bundled into the core functions. The sizes of the core functionalities are:

Apart from that there can be a small overhead depending on which utilities and wrappers you use.

There also exists a useful wrapper for some frameworks:

Performance

The package was optimized for performance:

  • the amount of network traffic is kept small
    The translation functions are small. Only the locales that are currently used are loaded.
  • no unecessary workload
    Parsing your translation file for variables and formatters will only be performed when you access a translation for the first time. The result of that parsing process will be stored in an optimized object and kept in memory.
  • fast translations
    Passing variables to the translation function will be fast, because its treated like a simple string concatenation. For formatting values, a single function is called per formatter.

If you use typesafe-i18n you will get a smaller bundle compared to other i18n solutions. But that does't mean, we should stop there. There are planned some possible optimizations, to improve the bundle size even further:

  • get rid of the arguments type informations inside your base-translation #13
  • rewrite keyed to index-based arguments #15
  • inline translations for single-locale bundles#14