@vocab/phrase 1.1.0 → 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
@@ -422,20 +422,81 @@ Or to re-run the compiler when files change use:
422
422
  $ vocab compile --watch
423
423
  ```
424
424
 
425
- ## External translation tooling
425
+ ## External Translation Tooling
426
426
 
427
427
  Vocab can be used to synchronize your translations with translations from a remote translation platform.
428
428
 
429
- | Platform | Environment Variables |
430
- | -------------------------------------------- | ----------------------------------- |
431
- | [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 |
432
432
 
433
433
  ```bash
434
434
  $ vocab push --branch my-branch
435
- $ vocab push --branch my-branch --delete-unused-keys
436
435
  $ vocab pull --branch my-branch
437
436
  ```
438
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
+
439
500
  ## Troubleshooting
440
501
 
441
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,10 +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
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<{
5
+ export declare function pushTranslations(translationsByLanguage: TranslationsByLanguage, { devLanguage, branch }: {
6
+ devLanguage: string;
7
+ branch: string;
8
+ }): Promise<{
7
9
  uploadId: string;
8
10
  }>;
9
- export declare function deleteUnusedKeys(uploadId: string, locale: string, branch: string): Promise<void>;
11
+ export declare function deleteUnusedKeys(uploadId: string, branch: string): Promise<void>;
10
12
  export declare function ensureBranch(branch: string): Promise<void>;
@@ -4,7 +4,8 @@ interface PushOptions {
4
4
  deleteUnusedKeys?: boolean;
5
5
  }
6
6
  /**
7
- * 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.
8
9
  */
9
10
  export declare function push({ branch, deleteUnusedKeys }: PushOptions, config: UserConfig): Promise<void>;
10
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
 
@@ -109,31 +148,56 @@ 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
- log('Starting to upload:', locale, '\n');
124
- const {
125
- id
126
- } = 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`, {
127
182
  method: 'POST',
128
183
  body: formData
129
184
  });
130
- log('Upload ID:', id, '\n');
131
- 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
+
132
196
  return {
133
- uploadId: id
197
+ uploadId: result.id
134
198
  };
135
199
  }
136
- async function deleteUnusedKeys(uploadId, locale, branch) {
200
+ async function deleteUnusedKeys(uploadId, branch) {
137
201
  const query = `unmentioned_in_upload:${uploadId}`;
138
202
  const {
139
203
  records_affected
@@ -144,7 +208,6 @@ async function deleteUnusedKeys(uploadId, locale, branch) {
144
208
  },
145
209
  body: JSON.stringify({
146
210
  branch,
147
- locale_id: locale,
148
211
  q: query
149
212
  })
150
213
  });
@@ -180,7 +243,8 @@ async function pull({
180
243
 
181
244
  const allVocabTranslations = await core.loadAllTranslations({
182
245
  fallbacks: 'none',
183
- includeNodeModules: false
246
+ includeNodeModules: false,
247
+ withTags: true
184
248
  }, config);
185
249
 
186
250
  for (const loadedTranslation of allVocabTranslations) {
@@ -198,6 +262,11 @@ async function pull({
198
262
  defaultValues[key] = { ...defaultValues[key],
199
263
  ...allPhraseTranslations[config.devLanguage][core.getUniqueKey(key, loadedTranslation.namespace)]
200
264
  };
265
+ } // Only write a `_meta` field if necessary
266
+
267
+
268
+ if (Object.keys(loadedTranslation.metadata).length > 0) {
269
+ defaultValues._meta = loadedTranslation.metadata;
201
270
  }
202
271
 
203
272
  await writeFile(loadedTranslation.filePath, `${JSON.stringify(defaultValues, null, 2)}\n`);
@@ -235,7 +304,8 @@ async function pull({
235
304
  }
236
305
 
237
306
  /**
238
- * 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.
239
309
  */
240
310
  async function push({
241
311
  branch,
@@ -243,7 +313,8 @@ async function push({
243
313
  }, config) {
244
314
  const allLanguageTranslations = await core.loadAllTranslations({
245
315
  fallbacks: 'none',
246
- includeNodeModules: false
316
+ includeNodeModules: false,
317
+ withTags: true
247
318
  }, config);
248
319
  trace(`Pushing translations to branch ${branch}`);
249
320
  const allLanguages = config.languages.map(v => v.name);
@@ -263,23 +334,37 @@ async function push({
263
334
  phraseTranslations[language] = {};
264
335
  }
265
336
 
337
+ const {
338
+ metadata: {
339
+ tags: sharedTags = []
340
+ }
341
+ } = loadedTranslation;
342
+
266
343
  for (const localKey of Object.keys(localTranslations)) {
267
344
  const phraseKey = core.getUniqueKey(localKey, loadedTranslation.namespace);
268
- 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;
269
355
  }
270
356
  }
271
357
  }
272
358
 
273
- for (const language of allLanguages) {
274
- if (phraseTranslations[language]) {
275
- const {
276
- uploadId
277
- } = await pushTranslationsByLocale(phraseTranslations[language], language, branch);
359
+ const {
360
+ uploadId
361
+ } = await pushTranslations(phraseTranslations, {
362
+ devLanguage: config.devLanguage,
363
+ branch
364
+ });
278
365
 
279
- if (deleteUnusedKeys$1) {
280
- await deleteUnusedKeys(uploadId, language, branch);
281
- }
282
- }
366
+ if (deleteUnusedKeys$1) {
367
+ await deleteUnusedKeys(uploadId, branch);
283
368
  }
284
369
  }
285
370
 
@@ -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
 
@@ -109,31 +148,56 @@ 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
- log('Starting to upload:', locale, '\n');
124
- const {
125
- id
126
- } = 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`, {
127
182
  method: 'POST',
128
183
  body: formData
129
184
  });
130
- log('Upload ID:', id, '\n');
131
- 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
+
132
196
  return {
133
- uploadId: id
197
+ uploadId: result.id
134
198
  };
135
199
  }
136
- async function deleteUnusedKeys(uploadId, locale, branch) {
200
+ async function deleteUnusedKeys(uploadId, branch) {
137
201
  const query = `unmentioned_in_upload:${uploadId}`;
138
202
  const {
139
203
  records_affected
@@ -144,7 +208,6 @@ async function deleteUnusedKeys(uploadId, locale, branch) {
144
208
  },
145
209
  body: JSON.stringify({
146
210
  branch,
147
- locale_id: locale,
148
211
  q: query
149
212
  })
150
213
  });
@@ -180,7 +243,8 @@ async function pull({
180
243
 
181
244
  const allVocabTranslations = await core.loadAllTranslations({
182
245
  fallbacks: 'none',
183
- includeNodeModules: false
246
+ includeNodeModules: false,
247
+ withTags: true
184
248
  }, config);
185
249
 
186
250
  for (const loadedTranslation of allVocabTranslations) {
@@ -198,6 +262,11 @@ async function pull({
198
262
  defaultValues[key] = { ...defaultValues[key],
199
263
  ...allPhraseTranslations[config.devLanguage][core.getUniqueKey(key, loadedTranslation.namespace)]
200
264
  };
265
+ } // Only write a `_meta` field if necessary
266
+
267
+
268
+ if (Object.keys(loadedTranslation.metadata).length > 0) {
269
+ defaultValues._meta = loadedTranslation.metadata;
201
270
  }
202
271
 
203
272
  await writeFile(loadedTranslation.filePath, `${JSON.stringify(defaultValues, null, 2)}\n`);
@@ -235,7 +304,8 @@ async function pull({
235
304
  }
236
305
 
237
306
  /**
238
- * 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.
239
309
  */
240
310
  async function push({
241
311
  branch,
@@ -243,7 +313,8 @@ async function push({
243
313
  }, config) {
244
314
  const allLanguageTranslations = await core.loadAllTranslations({
245
315
  fallbacks: 'none',
246
- includeNodeModules: false
316
+ includeNodeModules: false,
317
+ withTags: true
247
318
  }, config);
248
319
  trace(`Pushing translations to branch ${branch}`);
249
320
  const allLanguages = config.languages.map(v => v.name);
@@ -263,23 +334,37 @@ async function push({
263
334
  phraseTranslations[language] = {};
264
335
  }
265
336
 
337
+ const {
338
+ metadata: {
339
+ tags: sharedTags = []
340
+ }
341
+ } = loadedTranslation;
342
+
266
343
  for (const localKey of Object.keys(localTranslations)) {
267
344
  const phraseKey = core.getUniqueKey(localKey, loadedTranslation.namespace);
268
- 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;
269
355
  }
270
356
  }
271
357
  }
272
358
 
273
- for (const language of allLanguages) {
274
- if (phraseTranslations[language]) {
275
- const {
276
- uploadId
277
- } = await pushTranslationsByLocale(phraseTranslations[language], language, branch);
359
+ const {
360
+ uploadId
361
+ } = await pushTranslations(phraseTranslations, {
362
+ devLanguage: config.devLanguage,
363
+ branch
364
+ });
278
365
 
279
- if (deleteUnusedKeys$1) {
280
- await deleteUnusedKeys(uploadId, language, branch);
281
- }
282
- }
366
+ if (deleteUnusedKeys$1) {
367
+ await deleteUnusedKeys(uploadId, branch);
283
368
  }
284
369
  }
285
370