@vocab/react 1.0.2 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @vocab/react
2
2
 
3
+ ## 1.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [`240d6ad`](https://github.com/seek-oss/vocab/commit/240d6ad7e0cf43fed92655a2f95fb463bd7b6644) [#85](https://github.com/seek-oss/vocab/pull/85) Thanks [@askoufis](https://github.com/askoufis)! - The `t` function returned from `useTranslations` is now memoized. `t` should now only change after the initial loading of translations, and when the language changes, making it more useful inside a hook's dependency array.
8
+
9
+ ## 1.1.1
10
+
11
+ ### Patch Changes
12
+
13
+ - [`e9c7067`](https://github.com/seek-oss/vocab/commit/e9c7067b31215a176e70ac1e73f2c878107f328f) [#83](https://github.com/seek-oss/vocab/pull/83) Thanks [@michaeltaranto](https://github.com/michaeltaranto)! - Add React 18 support
14
+
15
+ ## 1.1.0
16
+
17
+ ### Minor Changes
18
+
19
+ - [`6de02b3`](https://github.com/seek-oss/vocab/commit/6de02b35839e8ecdd9016fec49a95e17d3696f87) [#69](https://github.com/seek-oss/vocab/pull/69) Thanks [@mattcompiles](https://github.com/mattcompiles)! - Automatically assign keys to React elements
20
+
21
+ Previously, when using React elements within translation templates, React would warn about missing keys as the return type is an array. This meant you needed to supply a key manually. Vocab can now automatically assign a key to React elements. Keys that are passed explicitly will remain untouched.
22
+
3
23
  ## 1.0.2
4
24
 
5
25
  ### Patch Changes
package/README.md CHANGED
@@ -184,6 +184,14 @@ Configuration can either be passed into the Node API directly or be gathered fro
184
184
  **vocab.config.js**
185
185
 
186
186
  ```js
187
+ function capitalize(element) {
188
+ return element.toUpperCase();
189
+ }
190
+
191
+ function pad(message) {
192
+ return '[' + message + ']';
193
+ }
194
+
187
195
  module.exports = {
188
196
  devLanguage: 'en',
189
197
  languages: [
@@ -192,11 +200,25 @@ module.exports = {
192
200
  { name: 'en-US', extends: 'en' },
193
201
  { name: 'fr-FR' }
194
202
  ],
203
+ /**
204
+ * An array of languages to generate based off translations for existing languages
205
+ * Default: []
206
+ */
207
+ generatedLanguages: [
208
+ {
209
+ name: 'generatedLangauge',
210
+ extends: 'en',
211
+ generator: {
212
+ transformElement: capitalize,
213
+ transformMessage: pad
214
+ }
215
+ }
216
+ ],
195
217
  /**
196
218
  * The root directory to compile and validate translations
197
219
  * Default: Current working directory
198
220
  */
199
- projectRoot: './example/';
221
+ projectRoot: './example/',
200
222
  /**
201
223
  * A custom suffix to name vocab translation directories
202
224
  * Default: '.vocab'
@@ -209,6 +231,131 @@ module.exports = {
209
231
  };
210
232
  ```
211
233
 
234
+ ## Generated languages
235
+
236
+ Vocab supports the creation of generated languages via the `generatedLanguages` config.
237
+
238
+ Generated languages are created by running a message `generator` over every translation message in an existing translation.
239
+ A `generator` may contain a `transformElement` function, a `transformMessage` function, or both.
240
+ Both of these functions accept a single string parameter and return a string.
241
+
242
+ `transformElement` is applied to string literal values contained within `MessageFormatElement`s.
243
+ A `MessageFormatElement` is an object representing a node in the AST of a compiled translation message.
244
+ Simply put, any text that would end up being translated by a translator, i.e. anything that is not part of the [ICU Message syntax], will be passed to `transformElement`.
245
+ An example of a use case for this function would be adding [diacritics] to every letter in order to stress your UI from a vertical line-height perspective.
246
+
247
+ `transformMessage` receives the entire translation message _after_ `transformElement` has been applied to its individual elements.
248
+ An example of a use case for this function would be adding padding text to the start/end of your messages in order to easily identify which text in your app has not been extracted into a `translations.json` file.
249
+
250
+ By default, a generated language's messages will be based off the `devLanguage`'s messages, but this can be overridden by providing an `extends` value that references another language.
251
+
252
+ **vocab.config.js**
253
+
254
+ ```js
255
+ function capitalize(message) {
256
+ return message.toUpperCase();
257
+ }
258
+
259
+ function pad(message) {
260
+ return '[' + message + ']';
261
+ }
262
+
263
+ module.exports = {
264
+ devLanguage: 'en',
265
+ languages: [{ name: 'en' }, { name: 'fr' }],
266
+ generatedLanguages: [
267
+ {
268
+ name: 'generatedLanguage',
269
+ extends: 'en',
270
+ generator: {
271
+ transformElement: capitalize,
272
+ transformMessage: pad
273
+ }
274
+ }
275
+ ]
276
+ };
277
+ ```
278
+
279
+ Generated languages are consumed the same way as regular languages.
280
+ Any Vocab API that accepts a `language` parameter will work with a generated language as well as a regular language.
281
+
282
+ **App.tsx**
283
+
284
+ ```tsx
285
+ const App = () => (
286
+ <VocabProvider language="generatedLanguage">
287
+ ...
288
+ </VocabProvider>
289
+ );
290
+ ```
291
+
292
+ [icu message syntax]: https://formatjs.io/docs/intl-messageformat/#message-syntax
293
+ [diacritics]: https://en.wikipedia.org/wiki/Diacritic
294
+
295
+ ## Pseudo-localization
296
+
297
+ The `@vocab/pseudo-localize` package exports low-level functions that can be used for pseudo-localization of translation messages.
298
+
299
+ ```ts
300
+ import {
301
+ extendVowels,
302
+ padString,
303
+ pseudoLocalize,
304
+ substituteCharacters
305
+ } from '@vocab/pseudo-localize';
306
+
307
+ const message = 'Hello';
308
+
309
+ // [Hello]
310
+ const paddedMessage = padString(message);
311
+
312
+ // Ḩẽƚƚö
313
+ const substitutedMessage = substituteCharacters(message);
314
+
315
+ // Heelloo
316
+ const extendedMessage = extendVowels(message);
317
+
318
+ // Extend the message and then substitute characters
319
+ // Ḩẽẽƚƚöö
320
+ const pseudoLocalizedMessage = pseudoLocalize(message);
321
+ ```
322
+
323
+ Pseudo-localization is a transformation that can be applied to a translation message.
324
+ Vocab's implementation of this transformation contains the following elements:
325
+
326
+ - _Start and end markers (`padString`):_ All strings are encapsulated in `[` and `]`. If a developer doesn’t see these characters they know the string has been clipped by an inflexible UI element.
327
+ - _Transformation of ASCII characters to extended character equivalents (`substituteCharacters`):_ Stresses the UI from a vertical line-height perspective, tests font and encoding support, and weeds out strings that haven’t been externalized correctly (they will not have the pseudo-localization applied to them).
328
+ - _Padding text (`extendVowels`):_ Simulates translation-induced expansion. Vocab's implementation of this involves repeating vowels (and `y`) to simulate a 40% expansion in the message's length.
329
+
330
+ This Netflix technology [blog post][blog post] inspired Vocab's implementation of this
331
+ functionality.
332
+
333
+ ### Generating a pseudo-localized language using Vocab
334
+
335
+ Vocab can generate a pseudo-localized language via the [`generatedLanguages` config][generated languages config], either via the webpack plugin or your `vocab.config.js` file.
336
+ `@vocab/pseudo-localize` exports a `generator` that can be used directly in your config.
337
+
338
+ **vocab.config.js**
339
+
340
+ ```js
341
+ const { generator } = require('@vocab/pseudo-localize');
342
+
343
+ module.exports = {
344
+ devLanguage: 'en',
345
+ languages: [{ name: 'en' }, { name: 'fr' }],
346
+ generatedLanguages: [
347
+ {
348
+ name: 'pseudo',
349
+ extends: 'en',
350
+ generator
351
+ }
352
+ ]
353
+ };
354
+ ```
355
+
356
+ [blog post]: https://netflixtechblog.com/pseudo-localization-netflix-12fff76fbcbe
357
+ [generated languages config]: #generated-languages
358
+
212
359
  ## Use without React
213
360
 
214
361
  If you need to use Vocab outside of React, you can access the returned Vocab file directly. You'll then be responsible for when to load translations and how to update on translation load.
@@ -1,11 +1,14 @@
1
1
  import { TranslationFile, LanguageName, ParsedFormatFnByKey, ParsedFormatFn } from '@vocab/types';
2
- import { FunctionComponent, ReactNode } from 'react';
2
+ import { ReactNode } from 'react';
3
3
  declare type Locale = string;
4
4
  interface TranslationsValue {
5
5
  language: LanguageName;
6
6
  locale?: Locale;
7
7
  }
8
- export declare const VocabProvider: FunctionComponent<TranslationsValue>;
8
+ interface VocabProviderProps extends TranslationsValue {
9
+ children: ReactNode;
10
+ }
11
+ export declare const VocabProvider: ({ children, language, locale, }: VocabProviderProps) => JSX.Element;
9
12
  export declare const useLanguage: () => TranslationsValue;
10
13
  declare type FormatXMLElementReactNodeFn = (parts: ReactNode[]) => ReactNode;
11
14
  declare type MapToReactNodeFunction<Params extends Record<string, any>> = {
@@ -43,6 +43,7 @@ function useTranslations(translations) {
43
43
  } = useLanguage();
44
44
  const [, forceRender] = React.useReducer(s => s + 1, 0);
45
45
  const translationsObject = translations.getLoadedMessages(language, locale || language);
46
+ let ready = true;
46
47
 
47
48
  if (!translationsObject) {
48
49
  if (SERVER_RENDERING) {
@@ -52,24 +53,40 @@ function useTranslations(translations) {
52
53
  translations.load(language).then(() => {
53
54
  forceRender();
54
55
  });
55
- return {
56
- t: () => ' ',
57
- ready: false
58
- };
56
+ ready = false;
59
57
  }
60
58
 
61
- const t = (key, params) => {
62
- if (!(translationsObject !== null && translationsObject !== void 0 && translationsObject[key])) {
59
+ const t = React.useCallback((key, params) => {
60
+ if (!translationsObject) {
61
+ return ' ';
62
+ }
63
+
64
+ const message = translationsObject === null || translationsObject === void 0 ? void 0 : translationsObject[key];
65
+
66
+ if (!message) {
63
67
  // eslint-disable-next-line no-console
64
68
  console.error(`Unable to find translation for key "${key}". Possible keys are ${Object.keys(translationsObject).map(v => `"${v}"`).join(', ')}`);
65
69
  return '';
66
70
  }
67
71
 
68
- return translationsObject[key].format(params);
69
- };
72
+ const result = message.format(params);
73
+
74
+ if (Array.isArray(result)) {
75
+ for (let i = 0; i < result.length; i++) {
76
+ const item = result[i];
77
+
78
+ if (typeof item === 'object' && item && !item.key && /*#__PURE__*/React.isValidElement(item)) {
79
+ result[i] = /*#__PURE__*/React.cloneElement(item, {
80
+ key: `_vocab-${i}`
81
+ });
82
+ }
83
+ }
84
+ }
70
85
 
86
+ return result;
87
+ }, [translationsObject]);
71
88
  return {
72
- ready: true,
89
+ ready,
73
90
  t
74
91
  };
75
92
  }
@@ -43,6 +43,7 @@ function useTranslations(translations) {
43
43
  } = useLanguage();
44
44
  const [, forceRender] = React.useReducer(s => s + 1, 0);
45
45
  const translationsObject = translations.getLoadedMessages(language, locale || language);
46
+ let ready = true;
46
47
 
47
48
  if (!translationsObject) {
48
49
  if (SERVER_RENDERING) {
@@ -52,24 +53,40 @@ function useTranslations(translations) {
52
53
  translations.load(language).then(() => {
53
54
  forceRender();
54
55
  });
55
- return {
56
- t: () => ' ',
57
- ready: false
58
- };
56
+ ready = false;
59
57
  }
60
58
 
61
- const t = (key, params) => {
62
- if (!(translationsObject !== null && translationsObject !== void 0 && translationsObject[key])) {
59
+ const t = React.useCallback((key, params) => {
60
+ if (!translationsObject) {
61
+ return ' ';
62
+ }
63
+
64
+ const message = translationsObject === null || translationsObject === void 0 ? void 0 : translationsObject[key];
65
+
66
+ if (!message) {
63
67
  // eslint-disable-next-line no-console
64
68
  console.error(`Unable to find translation for key "${key}". Possible keys are ${Object.keys(translationsObject).map(v => `"${v}"`).join(', ')}`);
65
69
  return '';
66
70
  }
67
71
 
68
- return translationsObject[key].format(params);
69
- };
72
+ const result = message.format(params);
73
+
74
+ if (Array.isArray(result)) {
75
+ for (let i = 0; i < result.length; i++) {
76
+ const item = result[i];
77
+
78
+ if (typeof item === 'object' && item && !item.key && /*#__PURE__*/React.isValidElement(item)) {
79
+ result[i] = /*#__PURE__*/React.cloneElement(item, {
80
+ key: `_vocab-${i}`
81
+ });
82
+ }
83
+ }
84
+ }
70
85
 
86
+ return result;
87
+ }, [translationsObject]);
71
88
  return {
72
- ready: true,
89
+ ready,
73
90
  t
74
91
  };
75
92
  }
@@ -1,4 +1,4 @@
1
- import React, { useMemo, useContext, useReducer } from 'react';
1
+ import React, { useMemo, useContext, useReducer, useCallback, isValidElement, cloneElement } from 'react';
2
2
 
3
3
  const TranslationsContext = /*#__PURE__*/React.createContext(undefined);
4
4
  const VocabProvider = ({
@@ -35,6 +35,7 @@ function useTranslations(translations) {
35
35
  } = useLanguage();
36
36
  const [, forceRender] = useReducer(s => s + 1, 0);
37
37
  const translationsObject = translations.getLoadedMessages(language, locale || language);
38
+ let ready = true;
38
39
 
39
40
  if (!translationsObject) {
40
41
  if (SERVER_RENDERING) {
@@ -44,24 +45,40 @@ function useTranslations(translations) {
44
45
  translations.load(language).then(() => {
45
46
  forceRender();
46
47
  });
47
- return {
48
- t: () => ' ',
49
- ready: false
50
- };
48
+ ready = false;
51
49
  }
52
50
 
53
- const t = (key, params) => {
54
- if (!(translationsObject !== null && translationsObject !== void 0 && translationsObject[key])) {
51
+ const t = useCallback((key, params) => {
52
+ if (!translationsObject) {
53
+ return ' ';
54
+ }
55
+
56
+ const message = translationsObject === null || translationsObject === void 0 ? void 0 : translationsObject[key];
57
+
58
+ if (!message) {
55
59
  // eslint-disable-next-line no-console
56
60
  console.error(`Unable to find translation for key "${key}". Possible keys are ${Object.keys(translationsObject).map(v => `"${v}"`).join(', ')}`);
57
61
  return '';
58
62
  }
59
63
 
60
- return translationsObject[key].format(params);
61
- };
64
+ const result = message.format(params);
65
+
66
+ if (Array.isArray(result)) {
67
+ for (let i = 0; i < result.length; i++) {
68
+ const item = result[i];
69
+
70
+ if (typeof item === 'object' && item && !item.key && /*#__PURE__*/isValidElement(item)) {
71
+ result[i] = /*#__PURE__*/cloneElement(item, {
72
+ key: `_vocab-${i}`
73
+ });
74
+ }
75
+ }
76
+ }
62
77
 
78
+ return result;
79
+ }, [translationsObject]);
63
80
  return {
64
- ready: true,
81
+ ready,
65
82
  t
66
83
  };
67
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vocab/react",
3
- "version": "1.0.2",
3
+ "version": "1.1.2",
4
4
  "main": "dist/vocab-react.cjs.js",
5
5
  "module": "dist/vocab-react.esm.js",
6
6
  "author": "SEEK",
@@ -13,7 +13,7 @@
13
13
  "intl-messageformat": "^9.9.0"
14
14
  },
15
15
  "devDependencies": {
16
- "@types/react": "^17.0.0",
17
- "react": "^17.0.1"
16
+ "@types/react": "^18.0.9",
17
+ "react": "^18.1.0"
18
18
  }
19
19
  }
package/src/index.tsx CHANGED
@@ -5,11 +5,13 @@ import {
5
5
  ParsedFormatFn,
6
6
  } from '@vocab/types';
7
7
  import React, {
8
- FunctionComponent,
9
8
  ReactNode,
10
9
  useContext,
11
10
  useMemo,
12
11
  useReducer,
12
+ isValidElement,
13
+ cloneElement,
14
+ useCallback,
13
15
  } from 'react';
14
16
 
15
17
  type Locale = string;
@@ -23,11 +25,14 @@ const TranslationsContext = React.createContext<TranslationsValue | undefined>(
23
25
  undefined,
24
26
  );
25
27
 
26
- export const VocabProvider: FunctionComponent<TranslationsValue> = ({
28
+ interface VocabProviderProps extends TranslationsValue {
29
+ children: ReactNode;
30
+ }
31
+ export const VocabProvider = ({
27
32
  children,
28
33
  language,
29
34
  locale,
30
- }) => {
35
+ }: VocabProviderProps) => {
31
36
  const value = useMemo(() => ({ language, locale }), [language, locale]);
32
37
 
33
38
  return (
@@ -49,6 +54,7 @@ export const useLanguage = (): TranslationsValue => {
49
54
  'Attempted to access translation without language set. Did you forget to pass language to VocabProvider?',
50
55
  );
51
56
  }
57
+
52
58
  return context;
53
59
  };
54
60
 
@@ -92,44 +98,70 @@ export function useTranslations<
92
98
  } {
93
99
  const { language, locale } = useLanguage();
94
100
  const [, forceRender] = useReducer((s: number) => s + 1, 0);
101
+
95
102
  const translationsObject = translations.getLoadedMessages(
96
103
  language as any,
97
104
  locale || language,
98
105
  );
99
106
 
107
+ let ready = true;
108
+
100
109
  if (!translationsObject) {
101
110
  if (SERVER_RENDERING) {
102
111
  throw new Error(
103
112
  `Translations not synchronously available on server render. Applying translations dynamically server-side is not supported.`,
104
113
  );
105
114
  }
115
+
106
116
  translations.load(language as any).then(() => {
107
117
  forceRender();
108
118
  });
109
- return {
110
- t: (() => ' ') as TranslateFn<ParsedFormatFnByKey>,
111
- ready: false,
112
- };
119
+ ready = false;
113
120
  }
114
121
 
115
- const t = (key: string, params?: any) => {
116
- if (!translationsObject?.[key]) {
117
- // eslint-disable-next-line no-console
118
- console.error(
119
- `Unable to find translation for key "${key}". Possible keys are ${Object.keys(
120
- translationsObject,
121
- )
122
- .map((v) => `"${v}"`)
123
- .join(', ')}`,
124
- );
125
- return '';
126
- }
122
+ const t = useCallback(
123
+ (key: string, params?: any) => {
124
+ if (!translationsObject) {
125
+ return ' ';
126
+ }
127
127
 
128
- return translationsObject[key].format(params);
129
- };
128
+ const message = translationsObject?.[key];
129
+
130
+ if (!message) {
131
+ // eslint-disable-next-line no-console
132
+ console.error(
133
+ `Unable to find translation for key "${key}". Possible keys are ${Object.keys(
134
+ translationsObject,
135
+ )
136
+ .map((v) => `"${v}"`)
137
+ .join(', ')}`,
138
+ );
139
+ return '';
140
+ }
141
+
142
+ const result = message.format(params);
143
+
144
+ if (Array.isArray(result)) {
145
+ for (let i = 0; i < result.length; i++) {
146
+ const item = result[i];
147
+ if (
148
+ typeof item === 'object' &&
149
+ item &&
150
+ !item.key &&
151
+ isValidElement(item)
152
+ ) {
153
+ result[i] = cloneElement(item, { key: `_vocab-${i}` });
154
+ }
155
+ }
156
+ }
157
+
158
+ return result;
159
+ },
160
+ [translationsObject],
161
+ );
130
162
 
131
163
  return {
132
- ready: true,
164
+ ready,
133
165
  t,
134
166
  };
135
167
  }