@vocab/phrase 1.0.1 → 1.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @vocab/phrase
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`66ed22c`](https://github.com/seek-oss/vocab/commit/66ed22cac6f89018d5fd69fd6f6408e090e1a382) [#93](https://github.com/seek-oss/vocab/pull/93) Thanks [@askoufis](https://github.com/askoufis)! - Add an optional `deleteUnusedKeys` flag to the `push` function. If set to `true`, unused keys will be deleted from Phrase after translations are pushed.
8
+
9
+ **EXAMPLE USAGE**:
10
+
11
+ ```js
12
+ import { push } from '@vocab/phrase';
13
+
14
+ const vocabConfig = {
15
+ devLanguage: 'en',
16
+ language: ['en', 'fr'],
17
+ };
18
+
19
+ await push({ branch: 'myBranch', deleteUnusedKeys: true }, vocabConfig);
20
+ ```
21
+
22
+ ### Patch Changes
23
+
24
+ - [`159d559`](https://github.com/seek-oss/vocab/commit/159d559c87c66c3e91c707fb45a1f67ebec07b4d) [#91](https://github.com/seek-oss/vocab/pull/91) Thanks [@askoufis](https://github.com/askoufis)! - Improve error message when Phrase doesn't return any translations for the dev language
25
+
3
26
  ## 1.0.1
4
27
 
5
28
  ### 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.
@@ -285,6 +432,7 @@ Vocab can be used to synchronize your translations with translations from a remo
285
432
 
286
433
  ```bash
287
434
  $ vocab push --branch my-branch
435
+ $ vocab push --branch my-branch --delete-unused-keys
288
436
  $ vocab pull --branch my-branch
289
437
  ```
290
438
 
@@ -1,7 +1,10 @@
1
1
  import { TranslationsByKey } from './../../types/src/index';
2
2
  import type { TranslationsByLanguage } from '@vocab/types';
3
3
  import fetch from 'node-fetch';
4
- export declare function callPhrase(relativePath: string, options?: Parameters<typeof fetch>[1]): Promise<any>;
4
+ export declare function callPhrase<T = any>(relativePath: string, options?: Parameters<typeof fetch>[1]): Promise<T>;
5
5
  export declare function pullAllTranslations(branch: string): Promise<TranslationsByLanguage>;
6
- export declare function pushTranslationsByLocale(contents: TranslationsByKey, locale: string, branch: string): Promise<void>;
6
+ export declare function pushTranslationsByLocale(contents: TranslationsByKey, locale: string, branch: string): Promise<{
7
+ uploadId: string;
8
+ }>;
9
+ export declare function deleteUnusedKeys(uploadId: string, locale: string, branch: string): Promise<void>;
7
10
  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,10 @@
1
1
  import { UserConfig } from '@vocab/types';
2
2
  interface PushOptions {
3
3
  branch: string;
4
+ deleteUnusedKeys?: boolean;
4
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
8
  */
8
- export declare function push({ branch }: PushOptions, config: UserConfig): Promise<void>;
9
+ export declare function push({ branch, deleteUnusedKeys }: PushOptions, config: UserConfig): Promise<void>;
9
10
  export {};
@@ -44,14 +44,14 @@ function _callPhrase(path, options = {}) {
44
44
  }).then(async response => {
45
45
  console.log(`${path}: ${response.status} - ${response.statusText}`);
46
46
  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:
47
+ trace('\nLink:', response.headers.get('Link'), '\n'); // Print All Headers:
48
48
  // console.log(Array.from(r.headers.entries()));
49
49
 
50
50
  try {
51
51
  var _response$headers$get;
52
52
 
53
53
  const result = await response.json();
54
- console.log(`Internal Result (Length: ${result.length})\n`);
54
+ trace(`Internal Result (Length: ${result.length})\n`);
55
55
 
56
56
  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
57
  var _response$headers$get2, _response$headers$get3;
@@ -59,10 +59,10 @@ function _callPhrase(path, options = {}) {
59
59
  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
60
 
61
61
  if (!nextPageUrl) {
62
- throw new Error('Cant parse next page URL');
62
+ throw new Error("Can't parse next page URL");
63
63
  }
64
64
 
65
- console.log('Results recieved with next page: ', nextPageUrl);
65
+ console.log('Results received with next page: ', nextPageUrl);
66
66
  const nextPageResult = await _callPhrase(nextPageUrl, options);
67
67
  return [...result, ...nextPageResult];
68
68
  }
@@ -120,12 +120,35 @@ async function pushTranslationsByLocale(contents, locale, branch) {
120
120
  formData.append('locale_id', locale);
121
121
  formData.append('branch', branch);
122
122
  formData.append('update_translations', 'true');
123
- trace('Starting to upload:', locale);
124
- await callPhrase(`uploads`, {
123
+ log('Starting to upload:', locale, '\n');
124
+ const {
125
+ id
126
+ } = await callPhrase(`uploads`, {
125
127
  method: 'POST',
126
128
  body: formData
127
129
  });
130
+ log('Upload ID:', id, '\n');
128
131
  log('Successfully Uploaded:', locale, '\n');
132
+ return {
133
+ uploadId: id
134
+ };
135
+ }
136
+ async function deleteUnusedKeys(uploadId, locale, branch) {
137
+ const query = `unmentioned_in_upload:${uploadId}`;
138
+ const {
139
+ records_affected
140
+ } = await callPhrase('keys', {
141
+ method: 'DELETE',
142
+ headers: {
143
+ 'Content-Type': 'application/json'
144
+ },
145
+ body: JSON.stringify({
146
+ branch,
147
+ locale_id: locale,
148
+ q: query
149
+ })
150
+ });
151
+ log('Successfully deleted', records_affected, 'unused keys from branch', branch);
129
152
  }
130
153
  async function ensureBranch(branch) {
131
154
  await callPhrase(`branches`, {
@@ -137,7 +160,7 @@ async function ensureBranch(branch) {
137
160
  name: branch
138
161
  })
139
162
  });
140
- trace('Created branch:', branch);
163
+ log('Created branch:', branch);
141
164
  }
142
165
 
143
166
  async function pull({
@@ -148,6 +171,13 @@ async function pull({
148
171
  const alternativeLanguages = core.getAltLanguages(config);
149
172
  const allPhraseTranslations = await pullAllTranslations(branch);
150
173
  trace(`Pulling translations from Phrase for languages ${config.devLanguage} and ${alternativeLanguages.join(', ')}`);
174
+ const phraseLanguages = Object.keys(allPhraseTranslations);
175
+ trace(`Found Phrase translations for languages ${phraseLanguages.join(', ')}`);
176
+
177
+ if (!phraseLanguages.includes(config.devLanguage)) {
178
+ 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.`);
179
+ }
180
+
151
181
  const allVocabTranslations = await core.loadAllTranslations({
152
182
  fallbacks: 'none',
153
183
  includeNodeModules: false
@@ -208,7 +238,8 @@ async function pull({
208
238
  * Uploading to the Phrase API for each language. Adding a unique namespace to each key using file path they key came from
209
239
  */
210
240
  async function push({
211
- branch
241
+ branch,
242
+ deleteUnusedKeys: deleteUnusedKeys$1
212
243
  }, config) {
213
244
  const allLanguageTranslations = await core.loadAllTranslations({
214
245
  fallbacks: 'none',
@@ -241,7 +272,13 @@ async function push({
241
272
 
242
273
  for (const language of allLanguages) {
243
274
  if (phraseTranslations[language]) {
244
- await pushTranslationsByLocale(phraseTranslations[language], language, branch);
275
+ const {
276
+ uploadId
277
+ } = await pushTranslationsByLocale(phraseTranslations[language], language, branch);
278
+
279
+ if (deleteUnusedKeys$1) {
280
+ await deleteUnusedKeys(uploadId, language, branch);
281
+ }
245
282
  }
246
283
  }
247
284
  }
@@ -44,14 +44,14 @@ function _callPhrase(path, options = {}) {
44
44
  }).then(async response => {
45
45
  console.log(`${path}: ${response.status} - ${response.statusText}`);
46
46
  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:
47
+ trace('\nLink:', response.headers.get('Link'), '\n'); // Print All Headers:
48
48
  // console.log(Array.from(r.headers.entries()));
49
49
 
50
50
  try {
51
51
  var _response$headers$get;
52
52
 
53
53
  const result = await response.json();
54
- console.log(`Internal Result (Length: ${result.length})\n`);
54
+ trace(`Internal Result (Length: ${result.length})\n`);
55
55
 
56
56
  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
57
  var _response$headers$get2, _response$headers$get3;
@@ -59,10 +59,10 @@ function _callPhrase(path, options = {}) {
59
59
  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
60
 
61
61
  if (!nextPageUrl) {
62
- throw new Error('Cant parse next page URL');
62
+ throw new Error("Can't parse next page URL");
63
63
  }
64
64
 
65
- console.log('Results recieved with next page: ', nextPageUrl);
65
+ console.log('Results received with next page: ', nextPageUrl);
66
66
  const nextPageResult = await _callPhrase(nextPageUrl, options);
67
67
  return [...result, ...nextPageResult];
68
68
  }
@@ -120,12 +120,35 @@ async function pushTranslationsByLocale(contents, locale, branch) {
120
120
  formData.append('locale_id', locale);
121
121
  formData.append('branch', branch);
122
122
  formData.append('update_translations', 'true');
123
- trace('Starting to upload:', locale);
124
- await callPhrase(`uploads`, {
123
+ log('Starting to upload:', locale, '\n');
124
+ const {
125
+ id
126
+ } = await callPhrase(`uploads`, {
125
127
  method: 'POST',
126
128
  body: formData
127
129
  });
130
+ log('Upload ID:', id, '\n');
128
131
  log('Successfully Uploaded:', locale, '\n');
132
+ return {
133
+ uploadId: id
134
+ };
135
+ }
136
+ async function deleteUnusedKeys(uploadId, locale, branch) {
137
+ const query = `unmentioned_in_upload:${uploadId}`;
138
+ const {
139
+ records_affected
140
+ } = await callPhrase('keys', {
141
+ method: 'DELETE',
142
+ headers: {
143
+ 'Content-Type': 'application/json'
144
+ },
145
+ body: JSON.stringify({
146
+ branch,
147
+ locale_id: locale,
148
+ q: query
149
+ })
150
+ });
151
+ log('Successfully deleted', records_affected, 'unused keys from branch', branch);
129
152
  }
130
153
  async function ensureBranch(branch) {
131
154
  await callPhrase(`branches`, {
@@ -137,7 +160,7 @@ async function ensureBranch(branch) {
137
160
  name: branch
138
161
  })
139
162
  });
140
- trace('Created branch:', branch);
163
+ log('Created branch:', branch);
141
164
  }
142
165
 
143
166
  async function pull({
@@ -148,6 +171,13 @@ async function pull({
148
171
  const alternativeLanguages = core.getAltLanguages(config);
149
172
  const allPhraseTranslations = await pullAllTranslations(branch);
150
173
  trace(`Pulling translations from Phrase for languages ${config.devLanguage} and ${alternativeLanguages.join(', ')}`);
174
+ const phraseLanguages = Object.keys(allPhraseTranslations);
175
+ trace(`Found Phrase translations for languages ${phraseLanguages.join(', ')}`);
176
+
177
+ if (!phraseLanguages.includes(config.devLanguage)) {
178
+ 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.`);
179
+ }
180
+
151
181
  const allVocabTranslations = await core.loadAllTranslations({
152
182
  fallbacks: 'none',
153
183
  includeNodeModules: false
@@ -208,7 +238,8 @@ async function pull({
208
238
  * Uploading to the Phrase API for each language. Adding a unique namespace to each key using file path they key came from
209
239
  */
210
240
  async function push({
211
- branch
241
+ branch,
242
+ deleteUnusedKeys: deleteUnusedKeys$1
212
243
  }, config) {
213
244
  const allLanguageTranslations = await core.loadAllTranslations({
214
245
  fallbacks: 'none',
@@ -241,7 +272,13 @@ async function push({
241
272
 
242
273
  for (const language of allLanguages) {
243
274
  if (phraseTranslations[language]) {
244
- await pushTranslationsByLocale(phraseTranslations[language], language, branch);
275
+ const {
276
+ uploadId
277
+ } = await pushTranslationsByLocale(phraseTranslations[language], language, branch);
278
+
279
+ if (deleteUnusedKeys$1) {
280
+ await deleteUnusedKeys(uploadId, language, branch);
281
+ }
245
282
  }
246
283
  }
247
284
  }
@@ -32,14 +32,14 @@ function _callPhrase(path, options = {}) {
32
32
  }).then(async response => {
33
33
  console.log(`${path}: ${response.status} - ${response.statusText}`);
34
34
  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})`);
35
- console.log('\nLink:', response.headers.get('Link'), '\n'); // Print All Headers:
35
+ trace('\nLink:', response.headers.get('Link'), '\n'); // Print All Headers:
36
36
  // console.log(Array.from(r.headers.entries()));
37
37
 
38
38
  try {
39
39
  var _response$headers$get;
40
40
 
41
41
  const result = await response.json();
42
- console.log(`Internal Result (Length: ${result.length})\n`);
42
+ trace(`Internal Result (Length: ${result.length})\n`);
43
43
 
44
44
  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')) {
45
45
  var _response$headers$get2, _response$headers$get3;
@@ -47,10 +47,10 @@ function _callPhrase(path, options = {}) {
47
47
  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 : [];
48
48
 
49
49
  if (!nextPageUrl) {
50
- throw new Error('Cant parse next page URL');
50
+ throw new Error("Can't parse next page URL");
51
51
  }
52
52
 
53
- console.log('Results recieved with next page: ', nextPageUrl);
53
+ console.log('Results received with next page: ', nextPageUrl);
54
54
  const nextPageResult = await _callPhrase(nextPageUrl, options);
55
55
  return [...result, ...nextPageResult];
56
56
  }
@@ -108,12 +108,35 @@ async function pushTranslationsByLocale(contents, locale, branch) {
108
108
  formData.append('locale_id', locale);
109
109
  formData.append('branch', branch);
110
110
  formData.append('update_translations', 'true');
111
- trace('Starting to upload:', locale);
112
- await callPhrase(`uploads`, {
111
+ log('Starting to upload:', locale, '\n');
112
+ const {
113
+ id
114
+ } = await callPhrase(`uploads`, {
113
115
  method: 'POST',
114
116
  body: formData
115
117
  });
118
+ log('Upload ID:', id, '\n');
116
119
  log('Successfully Uploaded:', locale, '\n');
120
+ return {
121
+ uploadId: id
122
+ };
123
+ }
124
+ async function deleteUnusedKeys(uploadId, locale, branch) {
125
+ const query = `unmentioned_in_upload:${uploadId}`;
126
+ const {
127
+ records_affected
128
+ } = await callPhrase('keys', {
129
+ method: 'DELETE',
130
+ headers: {
131
+ 'Content-Type': 'application/json'
132
+ },
133
+ body: JSON.stringify({
134
+ branch,
135
+ locale_id: locale,
136
+ q: query
137
+ })
138
+ });
139
+ log('Successfully deleted', records_affected, 'unused keys from branch', branch);
117
140
  }
118
141
  async function ensureBranch(branch) {
119
142
  await callPhrase(`branches`, {
@@ -125,7 +148,7 @@ async function ensureBranch(branch) {
125
148
  name: branch
126
149
  })
127
150
  });
128
- trace('Created branch:', branch);
151
+ log('Created branch:', branch);
129
152
  }
130
153
 
131
154
  async function pull({
@@ -136,6 +159,13 @@ async function pull({
136
159
  const alternativeLanguages = getAltLanguages(config);
137
160
  const allPhraseTranslations = await pullAllTranslations(branch);
138
161
  trace(`Pulling translations from Phrase for languages ${config.devLanguage} and ${alternativeLanguages.join(', ')}`);
162
+ const phraseLanguages = Object.keys(allPhraseTranslations);
163
+ trace(`Found Phrase translations for languages ${phraseLanguages.join(', ')}`);
164
+
165
+ if (!phraseLanguages.includes(config.devLanguage)) {
166
+ 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.`);
167
+ }
168
+
139
169
  const allVocabTranslations = await loadAllTranslations({
140
170
  fallbacks: 'none',
141
171
  includeNodeModules: false
@@ -196,7 +226,8 @@ async function pull({
196
226
  * Uploading to the Phrase API for each language. Adding a unique namespace to each key using file path they key came from
197
227
  */
198
228
  async function push({
199
- branch
229
+ branch,
230
+ deleteUnusedKeys: deleteUnusedKeys$1
200
231
  }, config) {
201
232
  const allLanguageTranslations = await loadAllTranslations({
202
233
  fallbacks: 'none',
@@ -229,7 +260,13 @@ async function push({
229
260
 
230
261
  for (const language of allLanguages) {
231
262
  if (phraseTranslations[language]) {
232
- await pushTranslationsByLocale(phraseTranslations[language], language, branch);
263
+ const {
264
+ uploadId
265
+ } = await pushTranslationsByLocale(phraseTranslations[language], language, branch);
266
+
267
+ if (deleteUnusedKeys$1) {
268
+ await deleteUnusedKeys(uploadId, language, branch);
269
+ }
233
270
  }
234
271
  }
235
272
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vocab/phrase",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "main": "dist/vocab-phrase.cjs.js",
5
5
  "module": "dist/vocab-phrase.esm.js",
6
6
  "author": "SEEK",
package/src/phrase-api.ts CHANGED
@@ -31,14 +31,14 @@ function _callPhrase(path: string, options: Parameters<typeof fetch>[1] = {}) {
31
31
  'X-Rate-Limit-Reset',
32
32
  )} seconds remaining})`,
33
33
  );
34
- console.log('\nLink:', response.headers.get('Link'), '\n');
34
+ trace('\nLink:', response.headers.get('Link'), '\n');
35
35
  // Print All Headers:
36
36
  // console.log(Array.from(r.headers.entries()));
37
37
 
38
38
  try {
39
39
  const result = await response.json();
40
40
 
41
- console.log(`Internal Result (Length: ${result.length})\n`);
41
+ trace(`Internal Result (Length: ${result.length})\n`);
42
42
 
43
43
  if (
44
44
  (!options.method || options.method === 'GET') &&
@@ -48,10 +48,10 @@ function _callPhrase(path: string, options: Parameters<typeof fetch>[1] = {}) {
48
48
  response.headers.get('Link')?.match(/<([^>]*)>; rel=next/) ?? [];
49
49
 
50
50
  if (!nextPageUrl) {
51
- throw new Error('Cant parse next page URL');
51
+ throw new Error("Can't parse next page URL");
52
52
  }
53
53
 
54
- console.log('Results recieved with next page: ', nextPageUrl);
54
+ console.log('Results received with next page: ', nextPageUrl);
55
55
 
56
56
  const nextPageResult = (await _callPhrase(nextPageUrl, options)) as any;
57
57
 
@@ -66,10 +66,10 @@ function _callPhrase(path: string, options: Parameters<typeof fetch>[1] = {}) {
66
66
  });
67
67
  }
68
68
 
69
- export async function callPhrase(
69
+ export async function callPhrase<T = any>(
70
70
  relativePath: string,
71
71
  options: Parameters<typeof fetch>[1] = {},
72
- ) {
72
+ ): Promise<T> {
73
73
  const projectId = process.env.PHRASE_PROJECT_ID;
74
74
 
75
75
  if (!projectId) {
@@ -94,18 +94,23 @@ export async function callPhrase(
94
94
  export async function pullAllTranslations(
95
95
  branch: string,
96
96
  ): Promise<TranslationsByLanguage> {
97
- const phraseResult: Array<{
98
- key: { name: string };
99
- locale: { code: string };
100
- content: string;
101
- }> = await callPhrase(`translations?branch=${branch}&per_page=100`);
97
+ const phraseResult = await callPhrase<
98
+ Array<{
99
+ key: { name: string };
100
+ locale: { code: string };
101
+ content: string;
102
+ }>
103
+ >(`translations?branch=${branch}&per_page=100`);
104
+
102
105
  const translations: TranslationsByLanguage = {};
106
+
103
107
  for (const r of phraseResult) {
104
108
  if (!translations[r.locale.code]) {
105
109
  translations[r.locale.code] = {};
106
110
  }
107
111
  translations[r.locale.code][r.key.name] = { message: r.content };
108
112
  }
113
+
109
114
  return translations;
110
115
  }
111
116
 
@@ -126,13 +131,41 @@ export async function pushTranslationsByLocale(
126
131
  formData.append('branch', branch);
127
132
  formData.append('update_translations', 'true');
128
133
 
129
- trace('Starting to upload:', locale);
134
+ log('Starting to upload:', locale, '\n');
130
135
 
131
- await callPhrase(`uploads`, {
136
+ const { id } = await callPhrase<{ id: string }>(`uploads`, {
132
137
  method: 'POST',
133
138
  body: formData,
134
139
  });
140
+ log('Upload ID:', id, '\n');
135
141
  log('Successfully Uploaded:', locale, '\n');
142
+
143
+ return { uploadId: id };
144
+ }
145
+
146
+ export async function deleteUnusedKeys(
147
+ uploadId: string,
148
+ locale: string,
149
+ branch: string,
150
+ ) {
151
+ const query = `unmentioned_in_upload:${uploadId}`;
152
+ const { records_affected } = await callPhrase<{ records_affected: number }>(
153
+ 'keys',
154
+ {
155
+ method: 'DELETE',
156
+ headers: {
157
+ 'Content-Type': 'application/json',
158
+ },
159
+ body: JSON.stringify({ branch, locale_id: locale, q: query }),
160
+ },
161
+ );
162
+
163
+ log(
164
+ 'Successfully deleted',
165
+ records_affected,
166
+ 'unused keys from branch',
167
+ branch,
168
+ );
136
169
  }
137
170
 
138
171
  export async function ensureBranch(branch: string) {
@@ -143,5 +176,6 @@ export async function ensureBranch(branch: string) {
143
176
  },
144
177
  body: JSON.stringify({ name: branch }),
145
178
  });
146
- trace('Created branch:', branch);
179
+
180
+ log('Created branch:', branch);
147
181
  }
@@ -2,7 +2,7 @@ import path from 'path';
2
2
  import { pull } from './pull-translations';
3
3
  import { pullAllTranslations } from './phrase-api';
4
4
  import { writeFile } from './file';
5
- import { LanguageTarget } from '@vocab/types';
5
+ import { GeneratedLanguageTarget, LanguageTarget } from '@vocab/types';
6
6
 
7
7
  jest.mock('./file', () => ({
8
8
  writeFile: jest.fn(() => Promise.resolve),
@@ -14,12 +14,17 @@ jest.mock('./phrase-api', () => ({
14
14
  pullAllTranslations: jest.fn(() => Promise.resolve({ en: {}, fr: {} })),
15
15
  }));
16
16
 
17
- function runPhrase(options: { languages: LanguageTarget[] }) {
17
+ const devLanguage = 'en';
18
+
19
+ function runPhrase(options: {
20
+ languages: LanguageTarget[];
21
+ generatedLanguages: GeneratedLanguageTarget[];
22
+ }) {
18
23
  return pull(
19
24
  { branch: 'tester' },
20
25
  {
21
26
  ...options,
22
- devLanguage: 'en',
27
+ devLanguage,
23
28
  projectRoot: path.resolve(__dirname, '..', '..', '..', 'fixtures/phrase'),
24
29
  },
25
30
  );
@@ -28,121 +33,182 @@ function runPhrase(options: { languages: LanguageTarget[] }) {
28
33
  describe('pull translations', () => {
29
34
  describe('when pulling translations for languages that already have translations', () => {
30
35
  beforeEach(() => {
31
- (pullAllTranslations as jest.Mock).mockClear();
32
- (writeFile as jest.Mock).mockClear();
33
- });
34
- (pullAllTranslations as jest.Mock).mockImplementation(() =>
35
- Promise.resolve({
36
- en: {
37
- 'hello.mytranslations': {
38
- message: 'Hi there',
36
+ jest.mocked(pullAllTranslations).mockClear();
37
+ jest.mocked(writeFile).mockClear();
38
+ jest.mocked(pullAllTranslations).mockImplementation(() =>
39
+ Promise.resolve({
40
+ en: {
41
+ 'hello.mytranslations': {
42
+ message: 'Hi there',
43
+ },
39
44
  },
40
- },
41
- fr: {
42
- 'hello.mytranslations': {
43
- message: 'merci',
45
+ fr: {
46
+ 'hello.mytranslations': {
47
+ message: 'merci',
48
+ },
44
49
  },
45
- },
46
- }),
47
- );
50
+ }),
51
+ );
52
+ });
48
53
 
49
54
  const options = {
50
55
  languages: [{ name: 'en' }, { name: 'fr' }],
56
+ generatedLanguages: [
57
+ {
58
+ name: 'generatedLanguage',
59
+ extends: 'en',
60
+ generator: {
61
+ transformMessage: (message: string) => `[${message}]`,
62
+ },
63
+ },
64
+ ],
51
65
  };
52
66
 
53
67
  it('should resolve', async () => {
54
68
  await expect(runPhrase(options)).resolves.toBeUndefined();
55
69
 
56
- expect(writeFile as jest.Mock).toHaveBeenCalledTimes(2);
70
+ expect(jest.mocked(writeFile)).toHaveBeenCalledTimes(2);
57
71
  });
58
72
 
59
73
  it('should update keys', async () => {
60
74
  await expect(runPhrase(options)).resolves.toBeUndefined();
61
75
 
62
76
  expect(
63
- (writeFile as jest.Mock).mock.calls.map(
64
- ([_filePath, contents]: [string, string]) => JSON.parse(contents),
65
- ),
77
+ jest
78
+ .mocked(writeFile)
79
+ .mock.calls.map(([_filePath, contents]) =>
80
+ JSON.parse(contents as string),
81
+ ),
66
82
  ).toMatchInlineSnapshot(`
67
- Array [
68
- Object {
69
- "hello": Object {
70
- "message": "Hi there",
83
+ [
84
+ {
85
+ "hello": {
86
+ "message": "Hi there",
87
+ },
88
+ "world": {
89
+ "message": "world",
90
+ },
71
91
  },
72
- "world": Object {
73
- "message": "world",
92
+ {
93
+ "hello": {
94
+ "message": "merci",
95
+ },
96
+ "world": {
97
+ "message": "monde",
98
+ },
74
99
  },
75
- },
76
- Object {
77
- "hello": Object {
78
- "message": "merci",
79
- },
80
- "world": Object {
81
- "message": "monde",
82
- },
83
- },
84
- ]
85
- `);
100
+ ]
101
+ `);
86
102
  });
87
103
  });
88
104
 
89
105
  describe('when pulling translations and some languages do not have any translations', () => {
90
106
  beforeEach(() => {
91
- (pullAllTranslations as jest.Mock).mockClear();
92
- (writeFile as jest.Mock).mockClear();
93
- });
94
- (pullAllTranslations as jest.Mock).mockImplementation(() =>
95
- Promise.resolve({
96
- en: {
97
- 'hello.mytranslations': {
98
- message: 'Hi there',
107
+ jest.mocked(pullAllTranslations).mockClear();
108
+ jest.mocked(writeFile).mockClear();
109
+ jest.mocked(pullAllTranslations).mockImplementation(() =>
110
+ Promise.resolve({
111
+ en: {
112
+ 'hello.mytranslations': {
113
+ message: 'Hi there',
114
+ },
99
115
  },
100
- },
101
- fr: {
102
- 'hello.mytranslations': {
103
- message: 'merci',
116
+ fr: {
117
+ 'hello.mytranslations': {
118
+ message: 'merci',
119
+ },
104
120
  },
105
- },
106
- }),
107
- );
121
+ }),
122
+ );
123
+ });
108
124
 
109
125
  const options = {
110
126
  languages: [{ name: 'en' }, { name: 'fr' }, { name: 'ja' }],
127
+ generatedLanguages: [
128
+ {
129
+ name: 'generatedLanguage',
130
+ extends: 'en',
131
+ generator: {
132
+ transformMessage: (message: string) => `[${message}]`,
133
+ },
134
+ },
135
+ ],
111
136
  };
112
137
 
113
138
  it('should resolve', async () => {
114
139
  await expect(runPhrase(options)).resolves.toBeUndefined();
115
140
 
116
- expect(writeFile as jest.Mock).toHaveBeenCalledTimes(2);
141
+ expect(jest.mocked(writeFile)).toHaveBeenCalledTimes(2);
117
142
  });
118
143
 
119
144
  it('should update keys', async () => {
120
145
  await expect(runPhrase(options)).resolves.toBeUndefined();
121
146
 
122
147
  expect(
123
- (writeFile as jest.Mock).mock.calls.map(
124
- ([_filePath, contents]: [string, string]) => JSON.parse(contents),
125
- ),
148
+ jest
149
+ .mocked(writeFile)
150
+ .mock.calls.map(([_filePath, contents]) =>
151
+ JSON.parse(contents as string),
152
+ ),
126
153
  ).toMatchInlineSnapshot(`
127
- Array [
128
- Object {
129
- "hello": Object {
130
- "message": "Hi there",
154
+ [
155
+ {
156
+ "hello": {
157
+ "message": "Hi there",
158
+ },
159
+ "world": {
160
+ "message": "world",
161
+ },
131
162
  },
132
- "world": Object {
133
- "message": "world",
163
+ {
164
+ "hello": {
165
+ "message": "merci",
166
+ },
167
+ "world": {
168
+ "message": "monde",
169
+ },
134
170
  },
135
- },
136
- Object {
137
- "hello": Object {
138
- "message": "merci",
171
+ ]
172
+ `);
173
+ });
174
+ });
175
+
176
+ describe('when pulling translations and the project has not configured translations for the dev language', () => {
177
+ beforeEach(() => {
178
+ jest.mocked(pullAllTranslations).mockClear();
179
+ jest.mocked(writeFile).mockClear();
180
+ jest.mocked(pullAllTranslations).mockImplementation(() =>
181
+ Promise.resolve({
182
+ fr: {
183
+ 'hello.mytranslations': {
184
+ message: 'merci',
185
+ },
139
186
  },
140
- "world": Object {
141
- "message": "monde",
187
+ }),
188
+ );
189
+ });
190
+
191
+ const options = {
192
+ languages: [{ name: 'en' }, { name: 'fr' }],
193
+ generatedLanguages: [
194
+ {
195
+ name: 'generatedLanguage',
196
+ extends: 'en',
197
+ generator: {
198
+ transformMessage: (message: string) => `[${message}]`,
142
199
  },
143
200
  },
144
- ]
145
- `);
201
+ ],
202
+ };
203
+
204
+ it('should throw an error', async () => {
205
+ await expect(runPhrase(options)).rejects.toThrow(
206
+ new Error(
207
+ `Phrase did not return any translations for the configured development language "en".\nPlease ensure this language is present in your Phrase project's configuration.`,
208
+ ),
209
+ );
210
+
211
+ expect(jest.mocked(writeFile)).toHaveBeenCalledTimes(0);
146
212
  });
147
213
  });
148
214
  });
@@ -14,6 +14,7 @@ import { trace } from './logger';
14
14
 
15
15
  interface PullOptions {
16
16
  branch?: string;
17
+ deleteUnusedKeys?: boolean;
17
18
  }
18
19
 
19
20
  export async function pull(
@@ -30,6 +31,17 @@ export async function pull(
30
31
  } and ${alternativeLanguages.join(', ')}`,
31
32
  );
32
33
 
34
+ const phraseLanguages = Object.keys(allPhraseTranslations);
35
+ trace(
36
+ `Found Phrase translations for languages ${phraseLanguages.join(', ')}`,
37
+ );
38
+
39
+ if (!phraseLanguages.includes(config.devLanguage)) {
40
+ throw new Error(
41
+ `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.`,
42
+ );
43
+ }
44
+
33
45
  const allVocabTranslations = await loadAllTranslations(
34
46
  { fallbacks: 'none', includeNodeModules: false },
35
47
  config,
@@ -1,6 +1,6 @@
1
1
  import path from 'path';
2
2
  import { push } from './push-translations';
3
- import { pushTranslationsByLocale } from './phrase-api';
3
+ import { pushTranslationsByLocale, deleteUnusedKeys } from './phrase-api';
4
4
  import { writeFile } from './file';
5
5
 
6
6
  jest.mock('./file', () => ({
@@ -11,55 +11,158 @@ jest.mock('./file', () => ({
11
11
  jest.mock('./phrase-api', () => ({
12
12
  ensureBranch: jest.fn(() => Promise.resolve()),
13
13
  pushTranslationsByLocale: jest.fn(() => Promise.resolve({ en: {}, fr: {} })),
14
+ deleteUnusedKeys: jest.fn(() => Promise.resolve()),
14
15
  }));
15
16
 
16
- function runPhrase() {
17
+ const uploadId = '1234';
18
+
19
+ function runPhrase(config: { deleteUnusedKeys: boolean }) {
17
20
  return push(
18
- { branch: 'tester' },
21
+ { branch: 'tester', deleteUnusedKeys: config.deleteUnusedKeys },
19
22
  {
20
23
  devLanguage: 'en',
21
24
  languages: [{ name: 'en' }, { name: 'fr' }],
25
+ generatedLanguages: [
26
+ {
27
+ name: 'generatedLanguage',
28
+ extends: 'en',
29
+ generator: {
30
+ transformMessage: (message: string) => `[${message}]`,
31
+ },
32
+ },
33
+ ],
22
34
  projectRoot: path.resolve(__dirname, '..', '..', '..', 'fixtures/phrase'),
23
35
  },
24
36
  );
25
37
  }
26
38
 
27
39
  describe('push', () => {
28
- beforeEach(() => {
29
- (pushTranslationsByLocale as jest.Mock).mockClear();
30
- (writeFile as jest.Mock).mockClear();
31
- });
32
- it('should resolve', async () => {
33
- await expect(runPhrase()).resolves.toBeUndefined();
40
+ describe('when deleteUnusedKeys is false', () => {
41
+ const config = { deleteUnusedKeys: false };
34
42
 
35
- expect(pushTranslationsByLocale as jest.Mock).toHaveBeenCalledTimes(2);
36
- });
37
- it('should update keys', async () => {
38
- await expect(runPhrase()).resolves.toBeUndefined();
43
+ beforeEach(() => {
44
+ jest.mocked(pushTranslationsByLocale).mockClear();
45
+ jest.mocked(writeFile).mockClear();
46
+ jest.mocked(deleteUnusedKeys).mockClear();
39
47
 
40
- expect(pushTranslationsByLocale as jest.Mock).toHaveBeenCalledWith(
41
- {
42
- 'hello.mytranslations': {
43
- message: 'Hello',
44
- },
45
- 'world.mytranslations': {
46
- message: 'world',
47
- },
48
- },
49
- 'en',
50
- 'tester',
51
- );
52
- expect(pushTranslationsByLocale as jest.Mock).toHaveBeenCalledWith(
53
- {
54
- 'hello.mytranslations': {
55
- message: 'Bonjour',
48
+ jest
49
+ .mocked(pushTranslationsByLocale)
50
+ .mockImplementation(() => Promise.resolve({ uploadId }));
51
+ });
52
+
53
+ it('should resolve', async () => {
54
+ await expect(runPhrase(config)).resolves.toBeUndefined();
55
+
56
+ expect(jest.mocked(pushTranslationsByLocale)).toHaveBeenCalledTimes(2);
57
+ });
58
+
59
+ it('should update keys', async () => {
60
+ await expect(runPhrase(config)).resolves.toBeUndefined();
61
+
62
+ expect(jest.mocked(pushTranslationsByLocale)).toHaveBeenCalledWith(
63
+ {
64
+ 'hello.mytranslations': {
65
+ message: 'Hello',
66
+ },
67
+ 'world.mytranslations': {
68
+ message: 'world',
69
+ },
56
70
  },
57
- 'world.mytranslations': {
58
- message: 'monde',
71
+ 'en',
72
+ 'tester',
73
+ );
74
+
75
+ expect(jest.mocked(pushTranslationsByLocale)).toHaveBeenCalledWith(
76
+ {
77
+ 'hello.mytranslations': {
78
+ message: 'Bonjour',
79
+ },
80
+ 'world.mytranslations': {
81
+ message: 'monde',
82
+ },
59
83
  },
60
- },
61
- 'fr',
62
- 'tester',
63
- );
84
+ 'fr',
85
+ 'tester',
86
+ );
87
+ });
88
+
89
+ it('should not delete unused keys', () => {
90
+ expect(deleteUnusedKeys).not.toHaveBeenCalled();
91
+ });
92
+ });
93
+
94
+ describe('when deleteUnusedKeys is true', () => {
95
+ const config = { deleteUnusedKeys: true };
96
+
97
+ beforeEach(() => {
98
+ jest.mocked(pushTranslationsByLocale).mockClear();
99
+ jest.mocked(writeFile).mockClear();
100
+ jest.mocked(deleteUnusedKeys).mockClear();
101
+ });
102
+
103
+ describe('and the upload succeeds', () => {
104
+ beforeEach(() => {
105
+ jest
106
+ .mocked(pushTranslationsByLocale)
107
+ .mockImplementation(() => Promise.resolve({ uploadId }));
108
+ });
109
+
110
+ it('should resolve', async () => {
111
+ await expect(runPhrase(config)).resolves.toBeUndefined();
112
+
113
+ expect(jest.mocked(pushTranslationsByLocale)).toHaveBeenCalledTimes(2);
114
+ });
115
+
116
+ it('should update keys', async () => {
117
+ await expect(runPhrase(config)).resolves.toBeUndefined();
118
+
119
+ expect(jest.mocked(pushTranslationsByLocale)).toHaveBeenCalledWith(
120
+ {
121
+ 'hello.mytranslations': {
122
+ message: 'Hello',
123
+ },
124
+ 'world.mytranslations': {
125
+ message: 'world',
126
+ },
127
+ },
128
+ 'en',
129
+ 'tester',
130
+ );
131
+
132
+ expect(jest.mocked(pushTranslationsByLocale)).toHaveBeenCalledWith(
133
+ {
134
+ 'hello.mytranslations': {
135
+ message: 'Bonjour',
136
+ },
137
+ 'world.mytranslations': {
138
+ message: 'monde',
139
+ },
140
+ },
141
+ 'fr',
142
+ 'tester',
143
+ );
144
+ });
145
+
146
+ it('should delete unused keys', async () => {
147
+ await expect(runPhrase(config)).resolves.toBeUndefined();
148
+
149
+ expect(deleteUnusedKeys).toHaveBeenCalledWith(uploadId, 'en', 'tester');
150
+ expect(deleteUnusedKeys).toHaveBeenCalledWith(uploadId, 'fr', 'tester');
151
+ });
152
+ });
153
+
154
+ describe('and the upload fails', () => {
155
+ beforeEach(() => {
156
+ jest
157
+ .mocked(pushTranslationsByLocale)
158
+ .mockImplementation(() => Promise.reject('Upload failed'));
159
+ });
160
+
161
+ it('should not delete unused keys', async () => {
162
+ await expect(runPhrase(config)).rejects.toBe('Upload failed');
163
+
164
+ expect(deleteUnusedKeys).not.toHaveBeenCalled();
165
+ });
166
+ });
64
167
  });
65
168
  });
@@ -1,18 +1,26 @@
1
1
  import { TranslationsByLanguage } from './../../types/src/index';
2
2
 
3
- import { ensureBranch, pushTranslationsByLocale } from './phrase-api';
3
+ import {
4
+ ensureBranch,
5
+ pushTranslationsByLocale,
6
+ deleteUnusedKeys as phraseDeleteUnusedKeys,
7
+ } from './phrase-api';
4
8
  import { trace } from './logger';
5
9
  import { loadAllTranslations, getUniqueKey } from '@vocab/core';
6
10
  import { UserConfig } from '@vocab/types';
7
11
 
8
12
  interface PushOptions {
9
13
  branch: string;
14
+ deleteUnusedKeys?: boolean;
10
15
  }
11
16
 
12
17
  /**
13
18
  * Uploading to the Phrase API for each language. Adding a unique namespace to each key using file path they key came from
14
19
  */
15
- export async function push({ branch }: PushOptions, config: UserConfig) {
20
+ export async function push(
21
+ { branch, deleteUnusedKeys }: PushOptions,
22
+ config: UserConfig,
23
+ ) {
16
24
  const allLanguageTranslations = await loadAllTranslations(
17
25
  { fallbacks: 'none', includeNodeModules: false },
18
26
  config,
@@ -45,11 +53,15 @@ export async function push({ branch }: PushOptions, config: UserConfig) {
45
53
 
46
54
  for (const language of allLanguages) {
47
55
  if (phraseTranslations[language]) {
48
- await pushTranslationsByLocale(
56
+ const { uploadId } = await pushTranslationsByLocale(
49
57
  phraseTranslations[language],
50
58
  language,
51
59
  branch,
52
60
  );
61
+
62
+ if (deleteUnusedKeys) {
63
+ await phraseDeleteUnusedKeys(uploadId, language, branch);
64
+ }
53
65
  }
54
66
  }
55
67
  }