@vocab/phrase 1.0.1 → 1.2.0

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/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.
@@ -275,19 +422,81 @@ Or to re-run the compiler when files change use:
275
422
  $ vocab compile --watch
276
423
  ```
277
424
 
278
- ## External translation tooling
425
+ ## External Translation Tooling
279
426
 
280
427
  Vocab can be used to synchronize your translations with translations from a remote translation platform.
281
428
 
282
- | Platform | Environment Variables |
283
- | -------------------------------------------- | ----------------------------------- |
284
- | [Phrase](https://developers.phrase.com/api/) | PHRASE_PROJECT_ID, PHRASE_API_TOKEN |
429
+ | Platform | Environment Variables |
430
+ | -------- | ----------------------------------- |
431
+ | [Phrase] | PHRASE_PROJECT_ID, PHRASE_API_TOKEN |
285
432
 
286
433
  ```bash
287
434
  $ vocab push --branch my-branch
288
435
  $ vocab pull --branch my-branch
289
436
  ```
290
437
 
438
+ ### [Phrase] Platform Features
439
+
440
+ #### Delete Unused keys
441
+
442
+ When uploading translations, Phrase identifies keys that exist in the Phrase project, but were not
443
+ referenced in the upload. These keys can be deleted from Phrase by providing the
444
+ `---delete-unused-keys` flag to `vocab push`. E.g.
445
+
446
+ ```sh
447
+ $ vocab push --branch my-branch --delete-unused-keys
448
+ ```
449
+
450
+ [phrase]: https://developers.phrase.com/api/
451
+
452
+ #### [Tags]
453
+
454
+ `vocab push` supports uploading [tags] to Phrase.
455
+
456
+ Tags can be added to an individual key via the `tags` property:
457
+
458
+ ```jsonc
459
+ // translations.json
460
+ {
461
+ "Hello": {
462
+ "message": "Hello",
463
+ "tags": ["greeting", "home_page"]
464
+ },
465
+ "Goodbye": {
466
+ "message": "Goodbye",
467
+ "tags": ["home_page"]
468
+ }
469
+ }
470
+ ```
471
+
472
+ Tags can also be added under a top-level `_meta` field. This will result in the tags applying to all
473
+ keys specified in the file:
474
+
475
+ ```jsonc
476
+ // translations.json
477
+ {
478
+ "_meta": {
479
+ "tags": ["home_page"]
480
+ },
481
+ "Hello": {
482
+ "message": "Hello",
483
+ "tags": ["greeting"]
484
+ },
485
+ "Goodbye": {
486
+ "message": "Goodbye"
487
+ }
488
+ }
489
+ ```
490
+
491
+ In the above example, both the `Hello` and `Goodbye` keys would have the `home_page` tag attached to
492
+ them, but only the `Hello` key would have the `usage_greeting` tag attached to it.
493
+
494
+ **NOTE**: Only tags specified on keys in your [`devLanguage`][configuration] will be uploaded.
495
+ Tags on keys in other languages will be ignored.
496
+
497
+ [tags]: https://support.phrase.com/hc/en-us/articles/5822598372252-Tags-Strings-
498
+ [configuration]: #Configuration
499
+
291
500
  ## Troubleshooting
292
501
 
293
502
  ### Problem: Passed locale is being ignored or using en-US instead
@@ -0,0 +1,10 @@
1
+ import type { TranslationsByLanguage } from '@vocab/types';
2
+ export declare function translationsToCsv(translations: TranslationsByLanguage, devLanguage: string): {
3
+ csvString: string;
4
+ localeMapping: {
5
+ [k: string]: number;
6
+ };
7
+ keyIndex: number;
8
+ commentIndex: number;
9
+ tagColumn: number;
10
+ };
@@ -1,7 +1,12 @@
1
- import { TranslationsByKey } from './../../types/src/index';
2
1
  import type { TranslationsByLanguage } from '@vocab/types';
3
2
  import fetch from 'node-fetch';
4
- export declare function callPhrase(relativePath: string, options?: Parameters<typeof fetch>[1]): Promise<any>;
3
+ export declare function callPhrase<T = any>(relativePath: string, options?: Parameters<typeof fetch>[1]): Promise<T>;
5
4
  export declare function pullAllTranslations(branch: string): Promise<TranslationsByLanguage>;
6
- export declare function pushTranslationsByLocale(contents: TranslationsByKey, locale: string, branch: string): Promise<void>;
5
+ export declare function pushTranslations(translationsByLanguage: TranslationsByLanguage, { devLanguage, branch }: {
6
+ devLanguage: string;
7
+ branch: string;
8
+ }): Promise<{
9
+ uploadId: string;
10
+ }>;
11
+ export declare function deleteUnusedKeys(uploadId: string, branch: string): Promise<void>;
7
12
  export declare function ensureBranch(branch: string): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import type { UserConfig } from '@vocab/types';
2
2
  interface PullOptions {
3
3
  branch?: string;
4
+ deleteUnusedKeys?: boolean;
4
5
  }
5
6
  export declare function pull({ branch }: PullOptions, config: UserConfig): Promise<void>;
6
7
  export {};
@@ -1,9 +1,11 @@
1
1
  import { UserConfig } from '@vocab/types';
2
2
  interface PushOptions {
3
3
  branch: string;
4
+ deleteUnusedKeys?: boolean;
4
5
  }
5
6
  /**
6
- * Uploading to the Phrase API for each language. Adding a unique namespace to each key using file path they key came from
7
+ * Uploads translations to the Phrase API for each language.
8
+ * A unique namespace is appended to each key using the file path the key came from.
7
9
  */
8
- export declare function push({ branch }: PushOptions, config: UserConfig): Promise<void>;
10
+ export declare function push({ branch, deleteUnusedKeys }: PushOptions, config: UserConfig): Promise<void>;
9
11
  export {};
@@ -9,6 +9,7 @@ var FormData = require('form-data');
9
9
  var fetch = require('node-fetch');
10
10
  var chalk = require('chalk');
11
11
  var debug = require('debug');
12
+ var sync = require('csv-stringify/sync');
12
13
 
13
14
  function _interopDefault (e) { return e && e.__esModule ? e : { 'default': e }; }
14
15
 
@@ -27,6 +28,44 @@ const log = (...params) => {
27
28
  console.log(chalk__default['default'].yellow('Vocab'), ...params);
28
29
  };
29
30
 
31
+ function translationsToCsv(translations, devLanguage) {
32
+ const languages = Object.keys(translations);
33
+ const altLanguages = languages.filter(language => language !== devLanguage); // Ensure languages are ordered for locale mapping
34
+
35
+ const orderedLanguages = [devLanguage, ...altLanguages];
36
+ const devLanguageTranslations = translations[devLanguage];
37
+ const csv = Object.entries(devLanguageTranslations).map(([key, {
38
+ message,
39
+ description,
40
+ tags
41
+ }]) => {
42
+ const altTranslationMessages = altLanguages.map(language => {
43
+ var _translations$languag, _translations$languag2;
44
+
45
+ return (_translations$languag = translations[language]) === null || _translations$languag === void 0 ? void 0 : (_translations$languag2 = _translations$languag[key]) === null || _translations$languag2 === void 0 ? void 0 : _translations$languag2.message;
46
+ });
47
+ return [message, ...altTranslationMessages, key, description, tags === null || tags === void 0 ? void 0 : tags.join(',')];
48
+ });
49
+ const csvString = sync.stringify(csv, {
50
+ delimiter: ',',
51
+ header: false
52
+ }); // Column indices start at 1
53
+
54
+ const localeMapping = Object.fromEntries(orderedLanguages.map((language, index) => [language, index + 1]));
55
+ const keyIndex = orderedLanguages.length + 1;
56
+ const commentIndex = keyIndex + 1;
57
+ const tagColumn = commentIndex + 1;
58
+ return {
59
+ csvString,
60
+ localeMapping,
61
+ keyIndex,
62
+ commentIndex,
63
+ tagColumn
64
+ };
65
+ }
66
+
67
+ /* eslint-disable no-console */
68
+
30
69
  function _callPhrase(path, options = {}) {
31
70
  const phraseApiToken = process.env.PHRASE_API_TOKEN;
32
71
 
@@ -44,14 +83,14 @@ function _callPhrase(path, options = {}) {
44
83
  }).then(async response => {
45
84
  console.log(`${path}: ${response.status} - ${response.statusText}`);
46
85
  console.log(`Rate Limit: ${response.headers.get('X-Rate-Limit-Remaining')} of ${response.headers.get('X-Rate-Limit-Limit')} remaining. (${response.headers.get('X-Rate-Limit-Reset')} seconds remaining})`);
47
- console.log('\nLink:', response.headers.get('Link'), '\n'); // Print All Headers:
86
+ trace('\nLink:', response.headers.get('Link'), '\n'); // Print All Headers:
48
87
  // console.log(Array.from(r.headers.entries()));
49
88
 
50
89
  try {
51
90
  var _response$headers$get;
52
91
 
53
92
  const result = await response.json();
54
- console.log(`Internal Result (Length: ${result.length})\n`);
93
+ trace(`Internal Result (Length: ${result.length})\n`);
55
94
 
56
95
  if ((!options.method || options.method === 'GET') && (_response$headers$get = response.headers.get('Link')) !== null && _response$headers$get !== void 0 && _response$headers$get.includes('rel=next')) {
57
96
  var _response$headers$get2, _response$headers$get3;
@@ -59,10 +98,10 @@ function _callPhrase(path, options = {}) {
59
98
  const [, nextPageUrl] = (_response$headers$get2 = (_response$headers$get3 = response.headers.get('Link')) === null || _response$headers$get3 === void 0 ? void 0 : _response$headers$get3.match(/<([^>]*)>; rel=next/)) !== null && _response$headers$get2 !== void 0 ? _response$headers$get2 : [];
60
99
 
61
100
  if (!nextPageUrl) {
62
- throw new Error('Cant parse next page URL');
101
+ throw new Error("Can't parse next page URL");
63
102
  }
64
103
 
65
- console.log('Results recieved with next page: ', nextPageUrl);
104
+ console.log('Results received with next page: ', nextPageUrl);
66
105
  const nextPageResult = await _callPhrase(nextPageUrl, options);
67
106
  return [...result, ...nextPageResult];
68
107
  }
@@ -109,23 +148,70 @@ async function pullAllTranslations(branch) {
109
148
 
110
149
  return translations;
111
150
  }
112
- async function pushTranslationsByLocale(contents, locale, branch) {
151
+ async function pushTranslations(translationsByLanguage, {
152
+ devLanguage,
153
+ branch
154
+ }) {
113
155
  const formData = new FormData__default['default']();
114
- const fileContents = Buffer.from(JSON.stringify(contents));
156
+ const {
157
+ csvString,
158
+ localeMapping,
159
+ keyIndex,
160
+ commentIndex,
161
+ tagColumn
162
+ } = translationsToCsv(translationsByLanguage, devLanguage);
163
+ const fileContents = Buffer.from(csvString);
115
164
  formData.append('file', fileContents, {
116
- contentType: 'application/json',
117
- filename: `${locale}.json`
165
+ contentType: 'text/csv',
166
+ filename: 'translations.csv'
118
167
  });
119
- formData.append('file_format', 'json');
120
- formData.append('locale_id', locale);
168
+ formData.append('file_format', 'csv');
121
169
  formData.append('branch', branch);
122
170
  formData.append('update_translations', 'true');
123
- trace('Starting to upload:', locale);
124
- await callPhrase(`uploads`, {
171
+
172
+ for (const [locale, index] of Object.entries(localeMapping)) {
173
+ formData.append(`locale_mapping[${locale}]`, index);
174
+ }
175
+
176
+ formData.append('format_options[key_index]', keyIndex);
177
+ formData.append('format_options[comment_index]', commentIndex);
178
+ formData.append('format_options[tag_column]', tagColumn);
179
+ formData.append('format_options[enable_pluralization]', 'false');
180
+ log('Uploading translations');
181
+ const result = await callPhrase(`uploads`, {
125
182
  method: 'POST',
126
183
  body: formData
127
184
  });
128
- log('Successfully Uploaded:', locale, '\n');
185
+ trace('Upload result:\n', result);
186
+
187
+ if (result && 'id' in result) {
188
+ log('Upload ID:', result.id, '\n');
189
+ log('Successfully Uploaded\n');
190
+ } else {
191
+ log(`Error uploading: ${result === null || result === void 0 ? void 0 : result.message}\n`);
192
+ log('Response:', result);
193
+ throw new Error('Error uploading');
194
+ }
195
+
196
+ return {
197
+ uploadId: result.id
198
+ };
199
+ }
200
+ async function deleteUnusedKeys(uploadId, branch) {
201
+ const query = `unmentioned_in_upload:${uploadId}`;
202
+ const {
203
+ records_affected
204
+ } = await callPhrase('keys', {
205
+ method: 'DELETE',
206
+ headers: {
207
+ 'Content-Type': 'application/json'
208
+ },
209
+ body: JSON.stringify({
210
+ branch,
211
+ q: query
212
+ })
213
+ });
214
+ log('Successfully deleted', records_affected, 'unused keys from branch', branch);
129
215
  }
130
216
  async function ensureBranch(branch) {
131
217
  await callPhrase(`branches`, {
@@ -137,7 +223,7 @@ async function ensureBranch(branch) {
137
223
  name: branch
138
224
  })
139
225
  });
140
- trace('Created branch:', branch);
226
+ log('Created branch:', branch);
141
227
  }
142
228
 
143
229
  async function pull({
@@ -148,9 +234,17 @@ async function pull({
148
234
  const alternativeLanguages = core.getAltLanguages(config);
149
235
  const allPhraseTranslations = await pullAllTranslations(branch);
150
236
  trace(`Pulling translations from Phrase for languages ${config.devLanguage} and ${alternativeLanguages.join(', ')}`);
237
+ const phraseLanguages = Object.keys(allPhraseTranslations);
238
+ trace(`Found Phrase translations for languages ${phraseLanguages.join(', ')}`);
239
+
240
+ if (!phraseLanguages.includes(config.devLanguage)) {
241
+ throw new Error(`Phrase did not return any translations for the configured development language "${config.devLanguage}".\nPlease ensure this language is present in your Phrase project's configuration.`);
242
+ }
243
+
151
244
  const allVocabTranslations = await core.loadAllTranslations({
152
245
  fallbacks: 'none',
153
- includeNodeModules: false
246
+ includeNodeModules: false,
247
+ withTags: true
154
248
  }, config);
155
249
 
156
250
  for (const loadedTranslation of allVocabTranslations) {
@@ -168,6 +262,11 @@ async function pull({
168
262
  defaultValues[key] = { ...defaultValues[key],
169
263
  ...allPhraseTranslations[config.devLanguage][core.getUniqueKey(key, loadedTranslation.namespace)]
170
264
  };
265
+ } // Only write a `_meta` field if necessary
266
+
267
+
268
+ if (Object.keys(loadedTranslation.metadata).length > 0) {
269
+ defaultValues._meta = loadedTranslation.metadata;
171
270
  }
172
271
 
173
272
  await writeFile(loadedTranslation.filePath, `${JSON.stringify(defaultValues, null, 2)}\n`);
@@ -205,14 +304,17 @@ async function pull({
205
304
  }
206
305
 
207
306
  /**
208
- * Uploading to the Phrase API for each language. Adding a unique namespace to each key using file path they key came from
307
+ * Uploads translations to the Phrase API for each language.
308
+ * A unique namespace is appended to each key using the file path the key came from.
209
309
  */
210
310
  async function push({
211
- branch
311
+ branch,
312
+ deleteUnusedKeys: deleteUnusedKeys$1
212
313
  }, config) {
213
314
  const allLanguageTranslations = await core.loadAllTranslations({
214
315
  fallbacks: 'none',
215
- includeNodeModules: false
316
+ includeNodeModules: false,
317
+ withTags: true
216
318
  }, config);
217
319
  trace(`Pushing translations to branch ${branch}`);
218
320
  const allLanguages = config.languages.map(v => v.name);
@@ -232,17 +334,37 @@ async function push({
232
334
  phraseTranslations[language] = {};
233
335
  }
234
336
 
337
+ const {
338
+ metadata: {
339
+ tags: sharedTags = []
340
+ }
341
+ } = loadedTranslation;
342
+
235
343
  for (const localKey of Object.keys(localTranslations)) {
236
344
  const phraseKey = core.getUniqueKey(localKey, loadedTranslation.namespace);
237
- phraseTranslations[language][phraseKey] = localTranslations[localKey];
345
+ const {
346
+ tags = [],
347
+ ...localTranslation
348
+ } = localTranslations[localKey];
349
+
350
+ if (language === config.devLanguage) {
351
+ localTranslation.tags = [...tags, ...sharedTags];
352
+ }
353
+
354
+ phraseTranslations[language][phraseKey] = localTranslation;
238
355
  }
239
356
  }
240
357
  }
241
358
 
242
- for (const language of allLanguages) {
243
- if (phraseTranslations[language]) {
244
- await pushTranslationsByLocale(phraseTranslations[language], language, branch);
245
- }
359
+ const {
360
+ uploadId
361
+ } = await pushTranslations(phraseTranslations, {
362
+ devLanguage: config.devLanguage,
363
+ branch
364
+ });
365
+
366
+ if (deleteUnusedKeys$1) {
367
+ await deleteUnusedKeys(uploadId, branch);
246
368
  }
247
369
  }
248
370