Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions app/javascript/packages/i18n/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# `@18f/identity-i18n`

JavaScript implementation of a Rails-like localization utility.

When paired with [`@18f/identity-rails-i18n-webpack-plugin`](https://github.com/18F/identity-idp/tree/main/app/javascript/packages/rails-i18n-webpack-plugin), it provides a seamless localization experience to retrieve locale data from [Rails locale data](https://github.com/18F/identity-idp/tree/main/config/locales).

## Usage

Usage should provide a behavior similar to [Rails Internationalization](https://guides.rubyonrails.org/i18n.html), where a given key would be expected to match locale data based on the folder structure found in `config/locales`.

For example, a key of `foo.bar.baz`, would match the file at `config/locales/foo/en.yml` (for English locales), whose content includes...

```yml
en:
foo:
bar:
baz: Message
```

### Basic

Call the translate function with a key to retrieve the translated message.

```yml
# config/locales/messages/en.yml
en:
messages:
greeting: Hello world!
```

```ts
import { t } from '@18f/identity-i18n';

t('messages.greeting');
// "Hello world!"
```

### Interpolation

Include an object of variables to interpolate those values in the matched entry.

```yml
# config/locales/messages/en.yml
en:
messages:
greeting: Hello %{recipient}!
```

```ts
import { t } from '@18f/identity-i18n';

t('messages.greeting', { recipient: 'world' });
// "Hello world!"
```

### Pluralization

An entry which is an object including `one` or `other` keys will automatically choose the correct message based on the `count` variable.

```yml
# config/locales/messages/en.yml
en:
messages:
greeting:
one: Hello to you!
other: Hello to all!
```

```ts
import { t } from '@18f/identity-i18n';

t('messages.greeting', { count: 1 });
// "Hello to you!"

t('messages.greeting', { count: 2 });
// "Hello to all!"
```

### Array Values

An entry may be a single string or an array of strings. Passing an array of key(s) will return an array of messages.

```yml
# config/locales/messages/en.yml
en:
messages:
greetings:
- Hello!
- Howdy!
```

```ts
import { t } from '@18f/identity-i18n';

t(['messages.greetings']);
// ["Hello!", "Howdy!"]
```
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe('I18n', () => {
strings: {
known: 'translation',
messages: { one: 'one message', other: '%{count} messages' },
list: ['one', 'two'],
},
});

Expand All @@ -28,6 +29,10 @@ describe('I18n', () => {
expect(t('known')).to.equal('translation');
});

it('returns multiple localized key values', () => {
expect(t(['known', 'known'])).to.deep.equal(['translation', 'translation']);
});

it('falls back to key value', () => {
expect(t('unknown')).to.equal('unknown');
});
Expand All @@ -45,5 +50,21 @@ describe('I18n', () => {
expect(t('messages', { count: 2 })).to.equal('2 messages');
});
});

describe('array entry', () => {
context('with a singular key', () => {
it('returns array of strings', () => {
expect(t('list')).to.deep.equal(['one', 'two']);
});
});

context('with an array of the key', () => {
it('returns array of strings', () => {
const list = t(['list']).map((value) => value);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For posterity, in case it raises eyebrows: The reason for the identity map is mostly to ensure that, when type-checked, TypeScript takes no issue with the fact that the return value would be expected to be an array, as an assurance of the contract that, when given an array of keys, the return value should be an array of messages.


expect(list).to.deep.equal(['one', 'two']);
});
});
});
});
});
42 changes: 34 additions & 8 deletions app/javascript/packages/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ interface PluralizedEntry {
other: string;
}

type Entry = string | PluralizedEntry;
type Entry = string | string[] | PluralizedEntry;
type Entries = Record<string, Entry>;
type Variables = Record<string, any>;

Expand Down Expand Up @@ -35,6 +35,25 @@ const getPluralizationKey = (count: number): keyof PluralizedEntry =>
const getEntry = (strings: Entries, key: string): Entry =>
hasOwn(strings, key) ? strings[key] : key;

/**
* Returns true if the given entry is a pluralization entry, or false otherwise.
*
* @param entry Entry to test.
*
* @return Whether entry is a pluralization entry.
*/
const isPluralizedEntry = (entry: Entry): entry is PluralizedEntry =>
typeof entry === 'object' && 'one' in entry;

/**
* Returns true if the given entry is a string entry, or false otherwise.
*
* @param entry Entry to test.
*
* @return Whether entry is a string entry.
*/
const isStringEntry = (entry: Entry): entry is string => typeof entry === 'string';

/**
* Returns the resulting string from the given entry, incorporating pluralization if necessary.
*
Expand All @@ -43,8 +62,8 @@ const getEntry = (strings: Entries, key: string): Entry =>
*
* @return Entry string.
*/
function getString(entry: Entry, count?: number): string {
if (typeof entry === 'object') {
function getString(entry: Entry, count?: number): string | string[] {
if (isPluralizedEntry(entry)) {
if (typeof count !== 'number') {
throw new TypeError('Expected count for PluralizedEntry');
}
Expand Down Expand Up @@ -77,15 +96,22 @@ class I18n {
/**
* Returns the translated string by the given key.
*
* @param key Key to retrieve.
* @param keyOrKeys Key or keys to retrieve.
* @param variables Variables to substitute in string.
*
* @return Translated string.
*/
t(key: string, variables: Variables = {}): string {
const entry = getEntry(this.strings, key);
const string = getString(entry, variables.count);
return replaceVariables(string, variables);
t(keyOrKeys: string, variables?: Variables): string;
t(keyOrKeys: string[], variables?: Variables): string[];
t(keyOrKeys: string | string[], variables: Variables = {}): string | string[] {
const isSingular = !Array.isArray(keyOrKeys);
const keys: string[] = isSingular ? [keyOrKeys] : keyOrKeys;
const entries = keys.map((key) => getEntry(this.strings, key));
const strings = entries
.map((entry) => (isPluralizedEntry(entry) ? getString(entry, variables?.count) : entry))
.map((entry) => (isStringEntry(entry) ? replaceVariables(entry, variables) : entry));

return isSingular ? strings[0] : strings.flat();
}
}

Expand Down