Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add minimal translation functionality without dependency on React #2554

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/early-wasps-shave.md
@@ -0,0 +1,5 @@
---
'@shopify/i18n': minor
---

Add SimpleI18n object in order to provide simple translation functionality without dependency on React
33 changes: 31 additions & 2 deletions packages/i18n/README.md
Expand Up @@ -4,7 +4,7 @@
[![Build Status](https://github.com/Shopify/quilt/workflows/Ruby-CI/badge.svg?branch=main)](https://github.com/Shopify/quilt/actions?query=workflow%3ARuby-CI)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md) [![npm version](https://badge.fury.io/js/%40shopify%2Fi18n.svg)](https://badge.fury.io/js/%40shopify%2Fi18n.svg) [![npm bundle size (minified + gzip)](https://img.shields.io/bundlephobia/minzip/@shopify/i18n.svg)](https://img.shields.io/bundlephobia/minzip/@shopify/i18n.svg)

Generic i18n-related utilities.
Generic i18n-related utilities and simple translate implementation.

## Installation

Expand All @@ -14,6 +14,35 @@ yarn add @shopify/i18n

## Usage

### `SimpleI18n`

Objec that provides an extremely simplified translate function that can be used without React integration.

- supports plurals and ordinals
- allows replacements using `{}` syntax
- translations must be passed in by user
- multiple translation libraries may be provided and are assumed to be in 'primary locale -> fallback locale' order

This is mean to provide a simple straightforward TypeScript only translation solution that aligns with Shopify's i18n practicies and is unlikely to be suitable for more complex requirements or large apps.

##### Initialization

```ts
const i18n = new SimpleI18n([{hello: 'Hello, {name}!'}], 'en');
```

##### Translation

```ts
simpleI18n.translate('hello', {bar: 'World'});
```

- translation keys may be nested
- translations should follow the [schema outlined in React-I18n](https://github.com/Shopify/quilt/blob/main/packages/react-i18n/docs/react_i18n_schema.md)
- this simplified translate function accepts the translation key and optional replacements as arguments
- please refer to [React-I18n's caveats](https://github.com/Shopify/quilt/tree/main/packages/react-i18n#intlpluralrules) regarding `Intl.PluralRules`
- you can use `translationKeyExists(key)` to check if a given key exists

### `pseudotranslate()`

Takes a string and returns a version of it that appears as if it were translated (learn more about [pseudotranslation](https://help.smartling.com/hc/en-us/articles/360000307573-Testing-with-Pseudo-Translation)).
Expand All @@ -25,7 +54,7 @@ This function accepts a number of arguments to customize the translation:
- `prepend` and `append`: strings to put at the start and end of the resulting string, respectively. This can be used to provide a common set of text around pseudotranslated code that can identify translated strings that are incorrectly joined together.

```ts
import {pseudotranslate} from '@shopify/react-i18n';
import {pseudotranslate} from '@shopify/i18n';

const pseudoOne = pseudoTranslate('cat'); // something like 'ͼααṭ'
const pseudoTwo = pseudoTranslate('cats: {names}', {
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/package.json
Expand Up @@ -33,6 +33,9 @@
],
"module": "index.mjs",
"esnext": "index.esnext",
"dependencies": {
"@shopify/function-enhancers": "^3.0.1"
},
"exports": {
".": {
"types": "./build/ts/index.d.ts",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/index.ts
@@ -1,2 +1,7 @@
export * from './locale';
export * from './pseudotranslate';
export * from './simpleI18n';
export type {
TranslationDictionary,
ReplacementDictionary,
} from './simpleI18nUtils/types';
185 changes: 185 additions & 0 deletions packages/i18n/src/simpleI18n.ts
@@ -0,0 +1,185 @@
import {languageFromLocale, regionFromLocale} from './locale';
import {
ReplacementDictionary,
TranslationDictionary,
} from './simpleI18nUtils/types';
import {memoizedNumberFormatter, memoizedPluralRules} from './simpleI18nUtils';

const CARDINAL_PLURALIZATION_KEY_NAME = 'count';
const ORDINAL_PLURALIZATION_KEY_NAME = 'ordinal';
const MISSING_TRANSLATION = Symbol('Missing translation');
const SEPARATOR = '.';
const isString = (value: any): value is string => typeof value === 'string';

/**
* Used to interpolate a string with values from an object.
* `{key}` will be replaced with `replacements[key]`.
*/
export const INTERPOLATE_FORMAT = /{\s*(\w+)\s*}/g;

export class SimpleI18n {
readonly locale: string;

get language() {
return languageFromLocale(this.locale);
}

get region() {
return regionFromLocale(this.locale);
}

constructor(
public readonly translations: TranslationDictionary[],
locale: string,
) {
this.locale = locale;
}

translate(id: string, replacements: ReplacementDictionary = {}): string {
for (const translationDictionary of this.translations) {
const result = this.translateWithDictionary(
id,
translationDictionary,
this.locale,
replacements,
);

if (result !== MISSING_TRANSLATION) {
return result;
}
}

throw new Error(
`Missing translation for key: ${id} in locale: ${this.locale}`,
);
}

translationKeyExists(id: string): boolean {
let result: string | TranslationDictionary;

for (const translationDictionary of this.translations) {
result = translationDictionary;

for (const part of id.split(SEPARATOR)) {
result = result[part];
if (!result) break;
}

if (result) {
return true;
}
}

return false;
}

private translateWithDictionary(
id: string,
translations: TranslationDictionary,
locale: string,
replacements?: ReplacementDictionary,
): string | typeof MISSING_TRANSLATION {
let result: string | TranslationDictionary = translations;

for (const part of id.split(SEPARATOR)) {
if (result == null || typeof result !== 'object') {
return MISSING_TRANSLATION;
}

result = result[part];
}

const additionalReplacements = {};

if (
typeof result === 'object' &&
replacements != null &&
Object.prototype.hasOwnProperty.call(
replacements,
CARDINAL_PLURALIZATION_KEY_NAME,
)
) {
const count = replacements[CARDINAL_PLURALIZATION_KEY_NAME];

if (typeof count === 'number') {
// Explicit 0 and 1 rules take precedence over the pluralization rules
// https://unicode-org.github.io/cldr/ldml/tr35-numbers.html#Explicit_0_1_rules
if (count === 0 && result['0'] !== undefined) {
result = result['0'];
} else if (count === 1 && result['1'] !== undefined) {
result = result['1'];
} else {
const group = memoizedPluralRules(locale).select(count);
result = result[group] || result.other;
}
additionalReplacements[CARDINAL_PLURALIZATION_KEY_NAME] =
memoizedNumberFormatter(locale).format(count);
}
} else if (
typeof result === 'object' &&
replacements != null &&
Object.prototype.hasOwnProperty.call(
replacements,
ORDINAL_PLURALIZATION_KEY_NAME,
)
) {
const count = replacements[ORDINAL_PLURALIZATION_KEY_NAME];

if (typeof count === 'number') {
const group = memoizedPluralRules(locale, {type: 'ordinal'}).select(
count,
);
result =
result.ordinal[group] ||
result.ordinal['other' as Intl.LDMLPluralRule];

additionalReplacements[ORDINAL_PLURALIZATION_KEY_NAME] =
memoizedNumberFormatter(locale).format(count);
}
}
if (!isString(result)) {
return MISSING_TRANSLATION;
}

return this.updateStringWithReplacements(result, {
...replacements,
...additionalReplacements,
});
}

private updateStringWithReplacements(
str: string,
replacements: ReplacementDictionary = {},
): string {
const replaceFinder = new RegExp(INTERPOLATE_FORMAT, 'g');

return str.replace(replaceFinder, (match) => {
const replacement = match.substring(1, match.length - 1).trim();

if (!Object.prototype.hasOwnProperty.call(replacements, replacement)) {
throw new Error(
this.replacementErrorMessage(replacements, replacement),
);
}

return replacements[replacement] as string;
});
}

private replacementErrorMessage(
replacements: ReplacementDictionary,
replacement: string,
): string {
let errorMessage = '';
const replacementKeys = Object.keys(replacements);

if (replacementKeys.length < 1) {
errorMessage = `No replacement found for key '${replacement}' (and no replacements were passed in).`;
} else {
errorMessage = `No replacement found for key '${replacement}'. The following replacements were passed: ${replacementKeys
.map((key) => `'${key}'`)
.join(', ')}`;
}
return errorMessage;
}
}
66 changes: 66 additions & 0 deletions packages/i18n/src/simpleI18nUtils/functionHelpers.ts
@@ -0,0 +1,66 @@
import {memoize as memoizeFn} from '@shopify/function-enhancers';

const UNICODE_NUMBERING_SYSTEM = '-u-nu-';
const LATIN = 'latn';

const numberFormats = new Map<string, Intl.NumberFormat>();

export function memoizedNumberFormatter(
locales?: string | string[],
options?: Intl.NumberFormatOptions,
) {
// force a latin locale for number formatting
const latnLocales = latinLocales(locales);
const key = numberFormatCacheKey(latnLocales, options);
if (numberFormats.has(key)) {
return numberFormats.get(key)!;
}
const i = new Intl.NumberFormat(latnLocales, options);
numberFormats.set(key, i);
return i;
}

function latinLocales(locales?: string | string[]) {
return Array.isArray(locales)
? locales.map((locale) => latinLocale(locale)!)
: latinLocale(locales);
}

function latinLocale(locale?: string) {
if (!locale) return locale;
// Intl.Locale was added to iOS in v14. See https://caniuse.com/?search=Intl.Locale
// We still support ios 12/13, so we need to check if this works and fallback to the default behaviour if not
try {
return new Intl.Locale(locale, {
numberingSystem: LATIN,
}).toString();
} catch {
const numberingSystemRegex = new RegExp(
`(?:-x|${UNICODE_NUMBERING_SYSTEM}).*`,
'g',
);
const latinNumberingSystem = `${UNICODE_NUMBERING_SYSTEM}${LATIN}`;
return locale
.replace(numberingSystemRegex, '')
.concat(latinNumberingSystem);
}
}

export function numberFormatCacheKey(
locales?: string | string[],
options: Intl.NumberFormatOptions = {},
) {
const localeKey = Array.isArray(locales) ? locales.sort().join('-') : locales;

return `${localeKey}-${JSON.stringify(options)}`;
}

function pluralRules(locale: string, options: Intl.PluralRulesOptions = {}) {
return new Intl.PluralRules(locale, options);
}

export const memoizedPluralRules = memoizeFn(
pluralRules,
(locale: string, options: Intl.PluralRulesOptions = {}) =>
`${locale}${JSON.stringify(options)}`,
);
1 change: 1 addition & 0 deletions packages/i18n/src/simpleI18nUtils/index.ts
@@ -0,0 +1 @@
export {memoizedNumberFormatter, memoizedPluralRules} from './functionHelpers';