@vocab/phrase 1.0.0 → 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,34 @@
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
+
26
+ ## 1.0.1
27
+
28
+ ### Patch Changes
29
+
30
+ - [`20eec77`](https://github.com/seek-oss/vocab/commit/20eec770705d05048ad8b32575cb92720b887f5b) [#76](https://github.com/seek-oss/vocab/pull/76) Thanks [@askoufis](https://github.com/askoufis)! - `vocab pull` no longer errors when phrase returns no translations for a configured language
31
+
3
32
  ## 1.0.0
4
33
 
5
34
  ### Major Changes
package/README.md CHANGED
@@ -110,7 +110,7 @@ So far, your app will run, but you're missing any translations other than the in
110
110
  }
111
111
  ```
112
112
 
113
- ### Step 6: [Optional] Setup Webpack plugin
113
+ ### Step 6: [Optional] Set up Webpack plugin
114
114
 
115
115
  Right now every language is loaded into your web application all the time, which could lead to a large bundle size. Ideally you will want to switch out the Node/default runtime for web runtime that will load only the active language.
116
116
 
@@ -171,8 +171,10 @@ In the below example we use two messages, one that passes in a single parameter
171
171
  Vocab will automatically parse these strings as ICU messages, identify the required parameters and ensure TypeScript knows the values must be passed in.
172
172
 
173
173
  ```tsx
174
- t('my key with param', {name: 'Vocab'});
175
- t('my key with component', {Link: children => (<a href="/foo">{children}</Link>)});
174
+ t('my key with param', { name: 'Vocab' });
175
+ t('my key with component', {
176
+ Link: (children) => <a href="/foo">{children}</a>
177
+ });
176
178
  ```
177
179
 
178
180
  ## Configuration
@@ -182,6 +184,14 @@ Configuration can either be passed into the Node API directly or be gathered fro
182
184
  **vocab.config.js**
183
185
 
184
186
  ```js
187
+ function capitalize(element) {
188
+ return element.toUpperCase();
189
+ }
190
+
191
+ function pad(message) {
192
+ return '[' + message + ']';
193
+ }
194
+
185
195
  module.exports = {
186
196
  devLanguage: 'en',
187
197
  languages: [
@@ -190,11 +200,25 @@ module.exports = {
190
200
  { name: 'en-US', extends: 'en' },
191
201
  { name: 'fr-FR' }
192
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
+ ],
193
217
  /**
194
218
  * The root directory to compile and validate translations
195
219
  * Default: Current working directory
196
220
  */
197
- projectRoot: ['./example/'];
221
+ projectRoot: './example/',
198
222
  /**
199
223
  * A custom suffix to name vocab translation directories
200
224
  * Default: '.vocab'
@@ -207,6 +231,131 @@ module.exports = {
207
231
  };
208
232
  ```
209
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
+
210
359
  ## Use without React
211
360
 
212
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.
@@ -283,6 +432,7 @@ Vocab can be used to synchronize your translations with translations from a remo
283
432
 
284
433
  ```bash
285
434
  $ vocab push --branch my-branch
435
+ $ vocab push --branch my-branch --delete-unused-keys
286
436
  $ vocab pull --branch my-branch
287
437
  ```
288
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
  }
@@ -83,7 +83,6 @@ async function callPhrase(relativePath, options = {}) {
83
83
  }
84
84
 
85
85
  return _callPhrase(`https://api.phrase.com/v2/projects/${projectId}/${relativePath}`, options).then(result => {
86
- // console.log('Result:', result);
87
86
  if (Array.isArray(result)) {
88
87
  console.log('Result length:', result.length);
89
88
  }
@@ -121,12 +120,35 @@ async function pushTranslationsByLocale(contents, locale, branch) {
121
120
  formData.append('locale_id', locale);
122
121
  formData.append('branch', branch);
123
122
  formData.append('update_translations', 'true');
124
- trace('Starting to upload:', locale);
125
- await callPhrase(`uploads`, {
123
+ log('Starting to upload:', locale, '\n');
124
+ const {
125
+ id
126
+ } = await callPhrase(`uploads`, {
126
127
  method: 'POST',
127
128
  body: formData
128
129
  });
130
+ log('Upload ID:', id, '\n');
129
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);
130
152
  }
131
153
  async function ensureBranch(branch) {
132
154
  await callPhrase(`branches`, {
@@ -138,7 +160,7 @@ async function ensureBranch(branch) {
138
160
  name: branch
139
161
  })
140
162
  });
141
- trace('Created branch:', branch);
163
+ log('Created branch:', branch);
142
164
  }
143
165
 
144
166
  async function pull({
@@ -149,6 +171,13 @@ async function pull({
149
171
  const alternativeLanguages = core.getAltLanguages(config);
150
172
  const allPhraseTranslations = await pullAllTranslations(branch);
151
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
+
152
181
  const allVocabTranslations = await core.loadAllTranslations({
153
182
  fallbacks: 'none',
154
183
  includeNodeModules: false
@@ -174,31 +203,33 @@ async function pull({
174
203
  await writeFile(loadedTranslation.filePath, `${JSON.stringify(defaultValues, null, 2)}\n`);
175
204
 
176
205
  for (const alternativeLanguage of alternativeLanguages) {
177
- const altTranslations = { ...loadedTranslation.languages[alternativeLanguage]
178
- };
179
- const phraseAltTranslations = allPhraseTranslations[alternativeLanguage];
206
+ if (alternativeLanguage in allPhraseTranslations) {
207
+ const altTranslations = { ...loadedTranslation.languages[alternativeLanguage]
208
+ };
209
+ const phraseAltTranslations = allPhraseTranslations[alternativeLanguage];
210
+
211
+ for (const key of localKeys) {
212
+ var _phraseAltTranslation;
180
213
 
181
- for (const key of localKeys) {
182
- var _phraseAltTranslation;
214
+ const phraseKey = core.getUniqueKey(key, loadedTranslation.namespace);
215
+ const phraseTranslationMessage = (_phraseAltTranslation = phraseAltTranslations[phraseKey]) === null || _phraseAltTranslation === void 0 ? void 0 : _phraseAltTranslation.message;
183
216
 
184
- const phraseKey = core.getUniqueKey(key, loadedTranslation.namespace);
185
- const phraseTranslationMessage = (_phraseAltTranslation = phraseAltTranslations[phraseKey]) === null || _phraseAltTranslation === void 0 ? void 0 : _phraseAltTranslation.message;
217
+ if (!phraseTranslationMessage) {
218
+ trace(`Missing translation. No translation for key ${key} in phrase as ${phraseKey} in language ${alternativeLanguage}.`);
219
+ continue;
220
+ }
186
221
 
187
- if (!phraseTranslationMessage) {
188
- trace(`Missing translation. No translation for key ${key} in phrase as ${phraseKey} in language ${alternativeLanguage}.`);
189
- continue;
222
+ altTranslations[key] = { ...altTranslations[key],
223
+ message: phraseTranslationMessage
224
+ };
190
225
  }
191
226
 
192
- altTranslations[key] = { ...altTranslations[key],
193
- message: phraseTranslationMessage
194
- };
227
+ const altTranslationFilePath = core.getAltLanguageFilePath(loadedTranslation.filePath, alternativeLanguage);
228
+ await mkdir(path__default['default'].dirname(altTranslationFilePath), {
229
+ recursive: true
230
+ });
231
+ await writeFile(altTranslationFilePath, `${JSON.stringify(altTranslations, null, 2)}\n`);
195
232
  }
196
-
197
- const altTranslationFilePath = core.getAltLanguageFilePath(loadedTranslation.filePath, alternativeLanguage);
198
- await mkdir(path__default['default'].dirname(altTranslationFilePath), {
199
- recursive: true
200
- });
201
- await writeFile(altTranslationFilePath, `${JSON.stringify(altTranslations, null, 2)}\n`);
202
233
  }
203
234
  }
204
235
  }
@@ -207,7 +238,8 @@ async function pull({
207
238
  * Uploading to the Phrase API for each language. Adding a unique namespace to each key using file path they key came from
208
239
  */
209
240
  async function push({
210
- branch
241
+ branch,
242
+ deleteUnusedKeys: deleteUnusedKeys$1
211
243
  }, config) {
212
244
  const allLanguageTranslations = await core.loadAllTranslations({
213
245
  fallbacks: 'none',
@@ -240,7 +272,13 @@ async function push({
240
272
 
241
273
  for (const language of allLanguages) {
242
274
  if (phraseTranslations[language]) {
243
- 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
+ }
244
282
  }
245
283
  }
246
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
  }
@@ -83,7 +83,6 @@ async function callPhrase(relativePath, options = {}) {
83
83
  }
84
84
 
85
85
  return _callPhrase(`https://api.phrase.com/v2/projects/${projectId}/${relativePath}`, options).then(result => {
86
- // console.log('Result:', result);
87
86
  if (Array.isArray(result)) {
88
87
  console.log('Result length:', result.length);
89
88
  }
@@ -121,12 +120,35 @@ async function pushTranslationsByLocale(contents, locale, branch) {
121
120
  formData.append('locale_id', locale);
122
121
  formData.append('branch', branch);
123
122
  formData.append('update_translations', 'true');
124
- trace('Starting to upload:', locale);
125
- await callPhrase(`uploads`, {
123
+ log('Starting to upload:', locale, '\n');
124
+ const {
125
+ id
126
+ } = await callPhrase(`uploads`, {
126
127
  method: 'POST',
127
128
  body: formData
128
129
  });
130
+ log('Upload ID:', id, '\n');
129
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);
130
152
  }
131
153
  async function ensureBranch(branch) {
132
154
  await callPhrase(`branches`, {
@@ -138,7 +160,7 @@ async function ensureBranch(branch) {
138
160
  name: branch
139
161
  })
140
162
  });
141
- trace('Created branch:', branch);
163
+ log('Created branch:', branch);
142
164
  }
143
165
 
144
166
  async function pull({
@@ -149,6 +171,13 @@ async function pull({
149
171
  const alternativeLanguages = core.getAltLanguages(config);
150
172
  const allPhraseTranslations = await pullAllTranslations(branch);
151
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
+
152
181
  const allVocabTranslations = await core.loadAllTranslations({
153
182
  fallbacks: 'none',
154
183
  includeNodeModules: false
@@ -174,31 +203,33 @@ async function pull({
174
203
  await writeFile(loadedTranslation.filePath, `${JSON.stringify(defaultValues, null, 2)}\n`);
175
204
 
176
205
  for (const alternativeLanguage of alternativeLanguages) {
177
- const altTranslations = { ...loadedTranslation.languages[alternativeLanguage]
178
- };
179
- const phraseAltTranslations = allPhraseTranslations[alternativeLanguage];
206
+ if (alternativeLanguage in allPhraseTranslations) {
207
+ const altTranslations = { ...loadedTranslation.languages[alternativeLanguage]
208
+ };
209
+ const phraseAltTranslations = allPhraseTranslations[alternativeLanguage];
210
+
211
+ for (const key of localKeys) {
212
+ var _phraseAltTranslation;
180
213
 
181
- for (const key of localKeys) {
182
- var _phraseAltTranslation;
214
+ const phraseKey = core.getUniqueKey(key, loadedTranslation.namespace);
215
+ const phraseTranslationMessage = (_phraseAltTranslation = phraseAltTranslations[phraseKey]) === null || _phraseAltTranslation === void 0 ? void 0 : _phraseAltTranslation.message;
183
216
 
184
- const phraseKey = core.getUniqueKey(key, loadedTranslation.namespace);
185
- const phraseTranslationMessage = (_phraseAltTranslation = phraseAltTranslations[phraseKey]) === null || _phraseAltTranslation === void 0 ? void 0 : _phraseAltTranslation.message;
217
+ if (!phraseTranslationMessage) {
218
+ trace(`Missing translation. No translation for key ${key} in phrase as ${phraseKey} in language ${alternativeLanguage}.`);
219
+ continue;
220
+ }
186
221
 
187
- if (!phraseTranslationMessage) {
188
- trace(`Missing translation. No translation for key ${key} in phrase as ${phraseKey} in language ${alternativeLanguage}.`);
189
- continue;
222
+ altTranslations[key] = { ...altTranslations[key],
223
+ message: phraseTranslationMessage
224
+ };
190
225
  }
191
226
 
192
- altTranslations[key] = { ...altTranslations[key],
193
- message: phraseTranslationMessage
194
- };
227
+ const altTranslationFilePath = core.getAltLanguageFilePath(loadedTranslation.filePath, alternativeLanguage);
228
+ await mkdir(path__default['default'].dirname(altTranslationFilePath), {
229
+ recursive: true
230
+ });
231
+ await writeFile(altTranslationFilePath, `${JSON.stringify(altTranslations, null, 2)}\n`);
195
232
  }
196
-
197
- const altTranslationFilePath = core.getAltLanguageFilePath(loadedTranslation.filePath, alternativeLanguage);
198
- await mkdir(path__default['default'].dirname(altTranslationFilePath), {
199
- recursive: true
200
- });
201
- await writeFile(altTranslationFilePath, `${JSON.stringify(altTranslations, null, 2)}\n`);
202
233
  }
203
234
  }
204
235
  }
@@ -207,7 +238,8 @@ async function pull({
207
238
  * Uploading to the Phrase API for each language. Adding a unique namespace to each key using file path they key came from
208
239
  */
209
240
  async function push({
210
- branch
241
+ branch,
242
+ deleteUnusedKeys: deleteUnusedKeys$1
211
243
  }, config) {
212
244
  const allLanguageTranslations = await core.loadAllTranslations({
213
245
  fallbacks: 'none',
@@ -240,7 +272,13 @@ async function push({
240
272
 
241
273
  for (const language of allLanguages) {
242
274
  if (phraseTranslations[language]) {
243
- 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
+ }
244
282
  }
245
283
  }
246
284
  }