@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 +23 -0
- package/README.md +149 -1
- package/dist/declarations/src/phrase-api.d.ts +5 -2
- package/dist/declarations/src/pull-translations.d.ts +1 -0
- package/dist/declarations/src/push-translations.d.ts +2 -1
- package/dist/vocab-phrase.cjs.dev.js +46 -9
- package/dist/vocab-phrase.cjs.prod.js +46 -9
- package/dist/vocab-phrase.esm.js +46 -9
- package/package.json +1 -1
- package/src/phrase-api.ts +48 -14
- package/src/pull-translations.test.ts +138 -72
- package/src/pull-translations.ts +12 -0
- package/src/push-translations.test.ts +138 -35
- package/src/push-translations.ts +15 -3
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<
|
|
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<
|
|
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,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
|
-
|
|
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
|
-
|
|
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('
|
|
62
|
+
throw new Error("Can't parse next page URL");
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
console.log('Results
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
62
|
+
throw new Error("Can't parse next page URL");
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
console.log('Results
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/vocab-phrase.esm.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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('
|
|
50
|
+
throw new Error("Can't parse next page URL");
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
console.log('Results
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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('
|
|
51
|
+
throw new Error("Can't parse next page URL");
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
console.log('Results
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
32
|
-
(writeFile
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
jest
|
|
78
|
+
.mocked(writeFile)
|
|
79
|
+
.mock.calls.map(([_filePath, contents]) =>
|
|
80
|
+
JSON.parse(contents as string),
|
|
81
|
+
),
|
|
66
82
|
).toMatchInlineSnapshot(`
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
83
|
+
[
|
|
84
|
+
{
|
|
85
|
+
"hello": {
|
|
86
|
+
"message": "Hi there",
|
|
87
|
+
},
|
|
88
|
+
"world": {
|
|
89
|
+
"message": "world",
|
|
90
|
+
},
|
|
71
91
|
},
|
|
72
|
-
|
|
73
|
-
"
|
|
92
|
+
{
|
|
93
|
+
"hello": {
|
|
94
|
+
"message": "merci",
|
|
95
|
+
},
|
|
96
|
+
"world": {
|
|
97
|
+
"message": "monde",
|
|
98
|
+
},
|
|
74
99
|
},
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
92
|
-
(writeFile
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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(
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
148
|
+
jest
|
|
149
|
+
.mocked(writeFile)
|
|
150
|
+
.mock.calls.map(([_filePath, contents]) =>
|
|
151
|
+
JSON.parse(contents as string),
|
|
152
|
+
),
|
|
126
153
|
).toMatchInlineSnapshot(`
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
154
|
+
[
|
|
155
|
+
{
|
|
156
|
+
"hello": {
|
|
157
|
+
"message": "Hi there",
|
|
158
|
+
},
|
|
159
|
+
"world": {
|
|
160
|
+
"message": "world",
|
|
161
|
+
},
|
|
131
162
|
},
|
|
132
|
-
|
|
133
|
-
"
|
|
163
|
+
{
|
|
164
|
+
"hello": {
|
|
165
|
+
"message": "merci",
|
|
166
|
+
},
|
|
167
|
+
"world": {
|
|
168
|
+
"message": "monde",
|
|
169
|
+
},
|
|
134
170
|
},
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
});
|
package/src/pull-translations.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
jest.mocked(pushTranslationsByLocale).mockClear();
|
|
45
|
+
jest.mocked(writeFile).mockClear();
|
|
46
|
+
jest.mocked(deleteUnusedKeys).mockClear();
|
|
39
47
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
'
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
});
|
package/src/push-translations.ts
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { TranslationsByLanguage } from './../../types/src/index';
|
|
2
2
|
|
|
3
|
-
import {
|
|
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(
|
|
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
|
}
|