@transifex/cli 2.0.1 → 2.2.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -82,7 +82,7 @@ USAGE
82
82
  $ txjs-cli push [PATTERN]
83
83
 
84
84
  ARGUMENTS
85
- PATTERN [default: **/*.{js,jsx,ts,tsx}] file pattern to scan for strings
85
+ PATTERN [default: **/*.{js,jsx,ts,tsx,vue}] file pattern to scan for strings
86
86
 
87
87
  OPTIONS
88
88
  -v, --verbose verbose output
@@ -98,7 +98,7 @@ OPTIONS
98
98
  --without-tags-only=without-tags-only push strings without specific tags
99
99
 
100
100
  DESCRIPTION
101
- Parse .js, .ts, .jsx, .tsx and .html files and detect phrases marked for
101
+ Parse .js, .ts, .jsx, .tsx, .html and .vue files and detect phrases marked for
102
102
  translation by Transifex Native toolkit for Javascript and
103
103
  upload them to Transifex for translation.
104
104
 
@@ -1 +1 @@
1
- {"version":"2.0.1","commands":{"invalidate":{"id":"invalidate","description":"invalidate and refresh CDS cache\nContent for delivery is cached in CDS and refreshed automatically every hour.\nThis command triggers a refresh of cached content on the fly.\n\nBy default, invalidation does not remove existing cached content, but\nstarts the process of updating with latest translations from Transifex.\n\nPassing the --purge option, cached content will be forced to be deleted,\nhowever use that with caution, as it may introduce downtime of\ntranslation delivery to the apps until fresh content is cached in the CDS.\n\nTo invalidate translations some environment variables must be set:\nTRANSIFEX_TOKEN=<Transifex Native Project Token>\nTRANSIFEX_SECRET=<Transifex Native Project Secret>\n(optional) TRANSIFEX_CDS_HOST=<CDS HOST>\n\nor passed as --token=<TOKEN> --secret=<SECRET> parameters\n\nDefault CDS Host is https://cds.svc.transifex.net\n\nExamples:\ntxjs-cli invalidate\ntxjs-cli invalidate --purge\ntxjs-cli invalidate --token=mytoken --secret=mysecret\nTRANSIFEX_TOKEN=mytoken TRANSIFEX_SECRET=mysecret txjs-cli invalidate\n","pluginName":"@transifex/cli","pluginType":"core","aliases":[],"flags":{"purge":{"name":"purge","type":"boolean","description":"force delete CDS cached content","allowNo":false},"token":{"name":"token","type":"option","description":"native project public token","default":""},"secret":{"name":"secret","type":"option","description":"native project secret","default":""},"cds-host":{"name":"cds-host","type":"option","description":"CDS host URL","default":""}},"args":[]},"push":{"id":"push","description":"detect and push source content to Transifex\nParse .js, .ts, .jsx, .tsx and .html files and detect phrases marked for\ntranslation by Transifex Native toolkit for Javascript and\nupload them to Transifex for translation.\n\nTo push content some environment variables must be set:\nTRANSIFEX_TOKEN=<Transifex Native Project Token>\nTRANSIFEX_SECRET=<Transifex Native Project Secret>\n(optional) TRANSIFEX_CDS_HOST=<CDS HOST>\n\nor passed as --token=<TOKEN> --secret=<SECRET> parameters\n\nDefault CDS Host is https://cds.svc.transifex.net\n\nExamples:\ntxjs-cli push -v\ntxjs-cli push src/\ntxjs-cli push /home/repo/src\ntxjs-cli push \"*.js\"\ntxjs-cli push --dry-run\ntxjs-cli push --no-wait\ntxjs-cli push --key-generator=hash\ntxjs-cli push --append-tags=\"master,release:2.5\"\ntxjs-cli push --with-tags-only=\"home,error\"\ntxjs-cli push --without-tags-only=\"custom\"\ntxjs-cli push --token=mytoken --secret=mysecret\nTRANSIFEX_TOKEN=mytoken TRANSIFEX_SECRET=mysecret txjs-cli push\n","pluginName":"@transifex/cli","pluginType":"core","aliases":[],"flags":{"dry-run":{"name":"dry-run","type":"boolean","description":"dry run, do not push to Transifex","allowNo":false},"verbose":{"name":"verbose","type":"boolean","char":"v","description":"verbose output","allowNo":false},"purge":{"name":"purge","type":"boolean","description":"purge content on Transifex","allowNo":false},"no-wait":{"name":"no-wait","type":"boolean","description":"disable polling for upload results","allowNo":false},"token":{"name":"token","type":"option","description":"native project public token","default":""},"secret":{"name":"secret","type":"option","description":"native project secret","default":""},"append-tags":{"name":"append-tags","type":"option","description":"append tags to strings","default":""},"with-tags-only":{"name":"with-tags-only","type":"option","description":"push strings with specific tags","default":""},"without-tags-only":{"name":"without-tags-only","type":"option","description":"push strings without specific tags","default":""},"cds-host":{"name":"cds-host","type":"option","description":"CDS host URL","default":""},"key-generator":{"name":"key-generator","type":"option","description":"use hashed or source based keys","options":["source","hash"],"default":"source"}},"args":[{"name":"pattern","description":"file pattern to scan for strings","required":false,"default":"**/*.{js,jsx,ts,tsx}"}]}}}
1
+ {"version":"2.2.0-alpha.0","commands":{"invalidate":{"id":"invalidate","description":"invalidate and refresh CDS cache\nContent for delivery is cached in CDS and refreshed automatically every hour.\nThis command triggers a refresh of cached content on the fly.\n\nBy default, invalidation does not remove existing cached content, but\nstarts the process of updating with latest translations from Transifex.\n\nPassing the --purge option, cached content will be forced to be deleted,\nhowever use that with caution, as it may introduce downtime of\ntranslation delivery to the apps until fresh content is cached in the CDS.\n\nTo invalidate translations some environment variables must be set:\nTRANSIFEX_TOKEN=<Transifex Native Project Token>\nTRANSIFEX_SECRET=<Transifex Native Project Secret>\n(optional) TRANSIFEX_CDS_HOST=<CDS HOST>\n\nor passed as --token=<TOKEN> --secret=<SECRET> parameters\n\nDefault CDS Host is https://cds.svc.transifex.net\n\nExamples:\ntxjs-cli invalidate\ntxjs-cli invalidate --purge\ntxjs-cli invalidate --token=mytoken --secret=mysecret\nTRANSIFEX_TOKEN=mytoken TRANSIFEX_SECRET=mysecret txjs-cli invalidate\n","pluginName":"@transifex/cli","pluginType":"core","aliases":[],"flags":{"purge":{"name":"purge","type":"boolean","description":"force delete CDS cached content","allowNo":false},"token":{"name":"token","type":"option","description":"native project public token","default":""},"secret":{"name":"secret","type":"option","description":"native project secret","default":""},"cds-host":{"name":"cds-host","type":"option","description":"CDS host URL","default":""}},"args":[]},"push":{"id":"push","description":"detect and push source content to Transifex\nParse .js, .ts, .jsx, .tsx and .html files and detect phrases marked for\ntranslation by Transifex Native toolkit for Javascript and\nupload them to Transifex for translation.\n\nTo push content some environment variables must be set:\nTRANSIFEX_TOKEN=<Transifex Native Project Token>\nTRANSIFEX_SECRET=<Transifex Native Project Secret>\n(optional) TRANSIFEX_CDS_HOST=<CDS HOST>\n\nor passed as --token=<TOKEN> --secret=<SECRET> parameters\n\nDefault CDS Host is https://cds.svc.transifex.net\n\nExamples:\ntxjs-cli push -v\ntxjs-cli push src/\ntxjs-cli push /home/repo/src\ntxjs-cli push \"*.js\"\ntxjs-cli push --dry-run\ntxjs-cli push --no-wait\ntxjs-cli push --key-generator=hash\ntxjs-cli push --append-tags=\"master,release:2.5\"\ntxjs-cli push --with-tags-only=\"home,error\"\ntxjs-cli push --without-tags-only=\"custom\"\ntxjs-cli push --token=mytoken --secret=mysecret\nTRANSIFEX_TOKEN=mytoken TRANSIFEX_SECRET=mysecret txjs-cli push\n","pluginName":"@transifex/cli","pluginType":"core","aliases":[],"flags":{"dry-run":{"name":"dry-run","type":"boolean","description":"dry run, do not push to Transifex","allowNo":false},"verbose":{"name":"verbose","type":"boolean","char":"v","description":"verbose output","allowNo":false},"purge":{"name":"purge","type":"boolean","description":"purge content on Transifex","allowNo":false},"no-wait":{"name":"no-wait","type":"boolean","description":"disable polling for upload results","allowNo":false},"token":{"name":"token","type":"option","description":"native project public token","default":""},"secret":{"name":"secret","type":"option","description":"native project secret","default":""},"append-tags":{"name":"append-tags","type":"option","description":"append tags to strings","default":""},"with-tags-only":{"name":"with-tags-only","type":"option","description":"push strings with specific tags","default":""},"without-tags-only":{"name":"without-tags-only","type":"option","description":"push strings without specific tags","default":""},"cds-host":{"name":"cds-host","type":"option","description":"CDS host URL","default":""},"key-generator":{"name":"key-generator","type":"option","description":"use hashed or source based keys","options":["source","hash"],"default":"source"}},"args":[{"name":"pattern","description":"file pattern to scan for strings","required":false,"default":"**/*.{js,jsx,ts,tsx}"}]}}}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@transifex/cli",
3
3
  "description": "Transifex Native CLI",
4
- "version": "2.0.1",
4
+ "version": "2.2.0-alpha.0",
5
5
  "author": "Transifex",
6
6
  "keywords": [
7
7
  "transifex",
@@ -27,14 +27,15 @@
27
27
  "@oclif/config": "^1.17.0",
28
28
  "@oclif/errors": "^1.3.4",
29
29
  "@oclif/plugin-help": "^3.2.1",
30
- "@transifex/native": "^2.0.1",
30
+ "@transifex/native": "^2.2.0-alpha.0",
31
31
  "angular-html-parser": "^1.7.1",
32
32
  "axios": "^0.24.0",
33
33
  "cli-ux": "^5.5.1",
34
34
  "colors": "^1.4.0",
35
35
  "glob": "^7.1.6",
36
36
  "lodash": "^4.17.21",
37
- "shelljs": "^0.8.4"
37
+ "shelljs": "^0.8.4",
38
+ "vue-template-compiler": "^2.6.14"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@oclif/dev-cli": "^1.26.0",
@@ -1,362 +1,11 @@
1
1
  /* eslint no-underscore-dangle: 0 */
2
- const ngHtmlParser = require('angular-html-parser');
3
2
 
4
3
  const fs = require('fs');
5
- const babelParser = require('@babel/parser');
6
- const babelTraverse = require('@babel/traverse').default;
7
- const _ = require('lodash');
8
4
  const path = require('path');
9
- const { generateKey, generateHashedKey } = require('@transifex/native');
10
5
 
11
- const { mergePayload } = require('./merge');
12
- const { stringToArray, mergeArrays } = require('./utils');
13
-
14
- /**
15
- * Create an extraction payload
16
- *
17
- * @param {String} string
18
- * @param {Object} params
19
- * @param {String} params._context
20
- * @param {String} params._comment
21
- * @param {Number} params._charlimit
22
- * @param {Number} params._tags
23
- * @param {String} occurence
24
- * @param {Object} options
25
- */
26
- function createPayload(string, params, occurence, options = {}) {
27
- return {
28
- string,
29
- key: options.useHashedKeys
30
- ? generateHashedKey(string, params)
31
- : generateKey(string, params),
32
- meta: _.omitBy({
33
- context: stringToArray(params._context || params.context),
34
- developer_comment: params._comment || params.comment,
35
- character_limit: params._charlimit || params.charlimit
36
- ? parseInt(params._charlimit || params.charlimit, 10)
37
- : undefined,
38
- tags: mergeArrays(stringToArray(params._tags || params.tags), options.appendTags),
39
- occurrences: [occurence],
40
- }, _.isNil),
41
- };
42
- }
43
-
44
- /**
45
- * Check if payload coming from createPayload is valid based on tag filters
46
- *
47
- * @param {Object} payload
48
- * @param {String[]} options.filterWithTags
49
- * @param {String[]} options.filterWithoutTags
50
- * @returns {Boolean}
51
- */
52
- function isPayloadValid(payload, options = {}) {
53
- const { filterWithTags, filterWithoutTags } = options;
54
- let isValid = true;
55
- _.each(filterWithTags, (tag) => {
56
- if (!_.includes(payload.meta.tags, tag)) {
57
- isValid = false;
58
- }
59
- });
60
- _.each(filterWithoutTags, (tag) => {
61
- if (_.includes(payload.meta.tags, tag)) {
62
- isValid = false;
63
- }
64
- });
65
- return isValid;
66
- }
67
-
68
- /**
69
- * Check if callee is a valid Transifex Native function
70
- *
71
- * @param {*} node
72
- * @returns {Boolean}
73
- */
74
- function isTransifexCall(node) {
75
- const { callee } = node;
76
- if (!callee) return false;
77
- if (_.includes(['t', 'useT'], callee.name)) { return true; }
78
- if (!callee.object || !callee.property) return false;
79
- if (callee.property.name === 'translate') return true;
80
- return false;
81
- }
82
-
83
- /**
84
- * Global regexp to find use of TranslatePipe.
85
- */
86
- const pipeRegexpG = /{{\s*?['|"]([\s\S]+?)['|"]\s*?\|\s*?translate\s*?:?({[\s\S]*?})?\s*?}}/gi;
87
-
88
- /**
89
- * Regexp to find use of TranslatePipe and match with capture groups.
90
- */
91
- const pipeRegexp = /{{\s*?['|"]([\s\S]+?)['|"]\s*?\|\s*?translate\s*?:?({[\s\S]*?})?\s*?}}/i;
92
-
93
- /**
94
- * Regexp to find use of TranslatePipe in Attributes;
95
- */
96
- const pipeBindingRegexp = /'([\s\S]+?)'\s*?\|\s*?translate\s*?:?({[\s\S]*?})?/i;
97
-
98
- /**
99
- * Loosely parses string (from HTML) to an object.
100
- *
101
- * According to Mozilla a bit better than eval().
102
- *
103
- * @param {str} obj
104
- * @returns {*}
105
- */
106
- function looseJsonParse(obj) {
107
- let parsed;
108
-
109
- try {
110
- // eslint-disable-next-line no-new-func
111
- parsed = Function(`"use strict";return (${obj})`)();
112
- } catch (err) {
113
- parsed = {};
114
- }
115
-
116
- return parsed;
117
- }
118
-
119
- /**
120
- * Parse an HTML file and detects T/UT tags and usage of TranslatePipe
121
- *
122
- * @param {Object} HASHES
123
- * @param {String} filename
124
- * @param {String} relativeFile
125
- * @param {String[]} appendTags
126
- * @param {Object} options
127
- * @returns void
128
- */
129
- function parseHTMLTemplateFile(HASHES, filename, relativeFile, options) {
130
- const TXComponents = [];
131
- const TXTemplateStrings = [];
132
-
133
- function parseTemplateText(text) {
134
- const textStr = _.trim(String(text));
135
-
136
- if (textStr.length) {
137
- const results = String(textStr).match(pipeRegexpG);
138
-
139
- if (results && results.length) {
140
- _.each(results, (result) => {
141
- const lineResult = result.match(pipeRegexp);
142
-
143
- if (lineResult) {
144
- const string = lineResult[1];
145
- const paramsStr = lineResult[2];
146
-
147
- const params = looseJsonParse(paramsStr) || {};
148
-
149
- if (string && params) {
150
- TXTemplateStrings.push({
151
- string,
152
- params,
153
- });
154
- }
155
- }
156
- });
157
- }
158
- }
159
- }
160
-
161
- function parseTemplateBindingText(text) {
162
- const textStr = _.trim(String(text));
163
-
164
- if (textStr.length) {
165
- const result = textStr.match(pipeBindingRegexp);
166
-
167
- if (result) {
168
- const string = result[1];
169
- const paramsStr = result[2];
170
-
171
- const params = looseJsonParse(paramsStr) || {};
172
-
173
- if (string && params) {
174
- TXTemplateStrings.push({
175
- string,
176
- params,
177
- });
178
- }
179
- }
180
- }
181
- }
182
-
183
- function parseTemplateNode(children) {
184
- if (children) {
185
- _.each(children, (child) => {
186
- if (child.name === 'T' || child.name === 'UT') {
187
- TXComponents.push(child);
188
- } else if (child.type === 'text') {
189
- parseTemplateText(child.value);
190
- } else if (child.attrs && child.attrs.length > 0) {
191
- const attributes = child.attrs.filter((a) => a.value.includes('translate'));
192
-
193
- _.each(attributes, (attr) => {
194
- parseTemplateBindingText(attr.value);
195
- });
196
- }
197
-
198
- parseTemplateNode(child.children);
199
- });
200
- }
201
- }
202
-
203
- const data = fs.readFileSync(filename, 'utf8');
204
- const { rootNodes, errors } = ngHtmlParser.parse(data);
205
- if (errors.length) return;
206
-
207
- parseTemplateNode(rootNodes);
208
- _.each(TXComponents, (txcmp) => {
209
- let string = '';
210
- let key = '';
211
- const params = {};
212
- if (txcmp.attrs) {
213
- _.each(txcmp.attrs, (attribute) => {
214
- if (attribute.name === 'str') {
215
- string = attribute.value;
216
- } else if (attribute.name === 'key') {
217
- key = attribute.value;
218
- } else {
219
- params[attribute.name] = attribute.value;
220
- }
221
- });
222
- }
223
- if (string) {
224
- const partial = createPayload(string, params, relativeFile, options);
225
- if (!isPayloadValid(partial, options)) return;
226
-
227
- mergePayload(HASHES, {
228
- [key || partial.key]: {
229
- string: partial.string,
230
- meta: partial.meta,
231
- },
232
- });
233
- }
234
- });
235
-
236
- _.each(TXTemplateStrings, (txStr) => {
237
- let key = '';
238
-
239
- if (txStr.params.key) {
240
- key = txStr.params.key;
241
- }
242
-
243
- const partial = createPayload(txStr.string, txStr.params, relativeFile, options);
244
- if (!isPayloadValid(partial, options)) return;
245
-
246
- mergePayload(HASHES, {
247
- [key || partial.key]: {
248
- string: partial.string,
249
- meta: partial.meta,
250
- },
251
- });
252
- });
253
- }
254
-
255
- function _parse(source) {
256
- try {
257
- return babelParser.parse(
258
- source,
259
- {
260
- sourceType: 'unambiguous',
261
- plugins: [
262
- 'decorators-legacy',
263
- 'classProperties',
264
- 'jsx',
265
- 'typescript',
266
- ],
267
- },
268
- );
269
- } catch (e) {
270
- return babelParser.parse(
271
- source,
272
- {
273
- sourceType: 'unambiguous',
274
- plugins: [
275
- 'decorators-legacy',
276
- 'jsx',
277
- 'flow',
278
- ],
279
- },
280
- );
281
- }
282
- }
283
-
284
- /**
285
- * Find value bound to some identifier with passed name.
286
- *
287
- * @param {Object} scope AST Scope to use for lookup.
288
- * @param {String} name Name of the identifier.
289
- * @returns {String?} declared value or null.
290
- */
291
- function findIdentifierValue(scope, name) {
292
- if (!scope) return null;
293
-
294
- if (scope.bindings[name]) {
295
- const binding = scope.bindings[name];
296
-
297
- if (binding.kind !== 'const') return null;
298
- const { node } = binding.path;
299
-
300
- if (node.type === 'VariableDeclarator' && node.init) {
301
- // eslint-disable-next-line no-use-before-define
302
- return findDeclaredValue(scope, node.init);
303
- }
304
- }
305
-
306
- if (scope.path.parentPath) {
307
- return findIdentifierValue(scope.path.parentPath.scope, name);
308
- }
309
-
310
- return null;
311
- }
312
-
313
- /**
314
- * Find declared value bound to identifier defined in init.
315
- *
316
- * @param {Object} scope AST Scope to use for lookup.
317
- * @param {Object} init AST Node to work with.
318
- * @returns {String?} declared value or null.
319
- */
320
- function findDeclaredValue(scope, init) {
321
- if (!init) return null;
322
-
323
- if (init.type === 'StringLiteral') {
324
- return init.value;
325
- }
326
-
327
- if (init.type === 'NumericLiteral') {
328
- return init.value;
329
- }
330
-
331
- if (init.type === 'JSXExpressionContainer') {
332
- return findDeclaredValue(scope, init.expression);
333
- }
334
-
335
- if (init.type === 'Identifier') {
336
- return findIdentifierValue(scope, init.name);
337
- }
338
-
339
- if (init.type === 'BinaryExpression' && init.operator === '+') {
340
- const left = findDeclaredValue(scope, init.left);
341
- const right = findDeclaredValue(scope, init.right);
342
-
343
- if (_.isString(left) && _.isString(right)) {
344
- return left + right;
345
- }
346
- }
347
-
348
- if (init.type === 'TemplateLiteral') {
349
- const expressions = init.expressions.map((node) => findDeclaredValue(scope, node));
350
- if (expressions.includes(null)) return null;
351
-
352
- const elements = init.quasis.flatMap(
353
- ({ tail, value }, i) => (tail ? value.raw : [value.raw, expressions[i]]),
354
- );
355
- return elements.join('');
356
- }
357
-
358
- return null;
359
- }
6
+ const { parseHTMLTemplateFile } = require('./parsers/angularHTML');
7
+ const { babelExtractPhrases } = require('./parsers/babel');
8
+ const { extractVuePhrases } = require('./parsers/vue');
360
9
 
361
10
  /**
362
11
  * Parse file and extract phrases using AST
@@ -373,122 +22,12 @@ function findDeclaredValue(scope, init) {
373
22
  function extractPhrases(file, relativeFile, options = {}) {
374
23
  const HASHES = {};
375
24
  const source = fs.readFileSync(file, 'utf8');
376
- if (path.extname(file) !== '.html') {
377
- const ast = _parse(source);
378
- babelTraverse(ast, {
379
- // T / UT functions
380
- CallExpression({ node, scope }) {
381
- // Check if node is a Transifex function
382
- if (!isTransifexCall(node)) return;
383
- if (_.isEmpty(node.arguments)) return;
384
-
385
- // Try to find the value of first argument
386
- const string = findDeclaredValue(scope, node.arguments[0]);
387
-
388
- // Verify that at least the string is passed to the function
389
- if (!_.isString(string)) return;
390
-
391
- // Extract function parameters
392
- const params = {};
393
- if (
394
- node.arguments[1]
395
- && node.arguments[1].type === 'ObjectExpression'
396
- ) {
397
- _.each(node.arguments[1].properties, (prop) => {
398
- // get only string on number params
399
- const value = findDeclaredValue(scope, prop.value);
400
- if (_.isString(value) || _.isNumber(value)) {
401
- params[prop.key.name] = value;
402
- }
403
- });
404
- }
405
-
406
- const partial = createPayload(string, params, relativeFile, options);
407
- if (!isPayloadValid(partial, options)) return;
408
-
409
- mergePayload(HASHES, {
410
- [partial.key]: {
411
- string: partial.string,
412
- meta: partial.meta,
413
- },
414
- });
415
- },
416
-
417
- // Decorator
418
- Decorator({ node }) {
419
- const elem = node.expression;
420
-
421
- if (!elem || !elem.arguments || !elem.arguments.length) return;
422
- if (!node.expression.callee.name === 'T') return;
423
-
424
- let string = '';
425
- let key = '';
426
- const params = {};
427
- _.each(node.expression.arguments, (arg) => {
428
- if (arg.type === 'StringLiteral') {
429
- string = arg.value;
430
- } else if (arg.type === 'ObjectExpression') {
431
- _.each(arg.properties, (prop) => {
432
- if (prop.key.name === '_key') {
433
- key = prop.value.value;
434
- } else {
435
- params[prop.key.name] = prop.value.value;
436
- }
437
- });
438
- }
439
- });
440
-
441
- if (string) {
442
- const partial = createPayload(string, params, relativeFile, options);
443
- if (!isPayloadValid(partial, options)) return;
444
-
445
- mergePayload(HASHES, {
446
- [key || partial.key]: {
447
- string: partial.string,
448
- meta: partial.meta,
449
- },
450
- });
451
- }
452
- },
453
-
454
- // React component
455
- JSXElement({ node, scope }) {
456
- const elem = node.openingElement;
457
-
458
- if (!elem || !elem.name) return;
459
- if (elem.name.name !== 'T' && elem.name.name !== 'UT') return;
460
-
461
- let string;
462
- const params = {};
463
- _.each(elem.attributes, (attr) => {
464
- const property = attr.name && attr.name.name;
465
- if (!property || !attr.value) return;
466
-
467
- const attrValue = findDeclaredValue(scope, attr.value);
468
- if (!attrValue) return;
469
-
470
- if (property === '_str') {
471
- string = attrValue;
472
- return;
473
- }
474
- params[property] = attrValue;
475
- });
476
-
477
- if (!string) return;
478
-
479
- const partial = createPayload(string, params, relativeFile, options);
480
- if (!isPayloadValid(partial, options)) return;
481
-
482
- mergePayload(HASHES, {
483
- [partial.key]: {
484
- string: partial.string,
485
- meta: partial.meta,
486
- },
487
- });
488
- },
489
- });
490
- } else if (path.extname(file) === '.html') {
25
+ if (path.extname(file) === '.html') {
491
26
  parseHTMLTemplateFile(HASHES, file, relativeFile, options);
27
+ } else if (path.extname(file) === '.vue') {
28
+ extractVuePhrases(HASHES, source, relativeFile, options);
29
+ } else if (path.extname(file) !== '.html') {
30
+ babelExtractPhrases(HASHES, source, relativeFile, options);
492
31
  }
493
32
 
494
33
  return HASHES;
@@ -0,0 +1,181 @@
1
+ const _ = require('lodash');
2
+ const ngHtmlParser = require('angular-html-parser');
3
+ const fs = require('fs');
4
+ const { mergePayload } = require('../merge');
5
+ const { createPayload, isPayloadValid } = require('./utils');
6
+
7
+ /**
8
+ * Global regexp to find use of TranslatePipe.
9
+ */
10
+ const pipeRegexpG = /{{\s*?['|"]([\s\S]+?)['|"]\s*?\|\s*?translate\s*?:?({[\s\S]*?})?\s*?}}/gi;
11
+
12
+ /**
13
+ * Regexp to find use of TranslatePipe and match with capture groups.
14
+ */
15
+ const pipeRegexp = /{{\s*?['|"]([\s\S]+?)['|"]\s*?\|\s*?translate\s*?:?({[\s\S]*?})?\s*?}}/i;
16
+
17
+ /**
18
+ * Regexp to find use of TranslatePipe in Attributes;
19
+ */
20
+ const pipeBindingRegexp = /'([\s\S]+?)'\s*?\|\s*?translate\s*?:?({[\s\S]*?})?/i;
21
+
22
+ /**
23
+ * Loosely parses string (from HTML) to an object.
24
+ *
25
+ * According to Mozilla a bit better than eval().
26
+ *
27
+ * @param {str} obj
28
+ * @returns {*}
29
+ */
30
+ function looseJsonParse(obj) {
31
+ let parsed;
32
+
33
+ try {
34
+ // eslint-disable-next-line no-new-func
35
+ parsed = Function(`"use strict";return (${obj})`)();
36
+ } catch (err) {
37
+ parsed = {};
38
+ }
39
+
40
+ return parsed;
41
+ }
42
+
43
+ /**
44
+ * Parse an HTML file and detects T/UT tags and usage of TranslatePipe
45
+ *
46
+ * @param {Object} HASHES
47
+ * @param {String} filename
48
+ * @param {String} relativeFile
49
+ * @param {String[]} appendTags
50
+ * @param {Object} options
51
+ * @returns void
52
+ */
53
+ function parseHTMLTemplateFile(HASHES, filename, relativeFile, options) {
54
+ const TXComponents = [];
55
+ const TXTemplateStrings = [];
56
+
57
+ function parseTemplateText(text) {
58
+ const textStr = _.trim(String(text));
59
+
60
+ if (textStr.length) {
61
+ const results = String(textStr).match(pipeRegexpG);
62
+
63
+ if (results && results.length) {
64
+ _.each(results, (result) => {
65
+ const lineResult = result.match(pipeRegexp);
66
+
67
+ if (lineResult) {
68
+ const string = lineResult[1];
69
+ const paramsStr = lineResult[2];
70
+
71
+ const params = looseJsonParse(paramsStr) || {};
72
+
73
+ if (string && params) {
74
+ TXTemplateStrings.push({
75
+ string,
76
+ params,
77
+ });
78
+ }
79
+ }
80
+ });
81
+ }
82
+ }
83
+ }
84
+
85
+ function parseTemplateBindingText(text) {
86
+ const textStr = _.trim(String(text));
87
+
88
+ if (textStr.length) {
89
+ const result = textStr.match(pipeBindingRegexp);
90
+
91
+ if (result) {
92
+ const string = result[1];
93
+ const paramsStr = result[2];
94
+
95
+ const params = looseJsonParse(paramsStr) || {};
96
+
97
+ if (string && params) {
98
+ TXTemplateStrings.push({
99
+ string,
100
+ params,
101
+ });
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ function parseTemplateNode(children) {
108
+ if (children) {
109
+ _.each(children, (child) => {
110
+ if (child.name === 'T' || child.name === 'UT') {
111
+ TXComponents.push(child);
112
+ } else if (child.type === 'text') {
113
+ parseTemplateText(child.value);
114
+ } else if (child.attrs && child.attrs.length > 0) {
115
+ const attributes = child.attrs.filter((a) => a.value.includes('translate'));
116
+
117
+ _.each(attributes, (attr) => {
118
+ parseTemplateBindingText(attr.value);
119
+ });
120
+ }
121
+
122
+ parseTemplateNode(child.children);
123
+ });
124
+ }
125
+ }
126
+
127
+ const data = fs.readFileSync(filename, 'utf8');
128
+ const { rootNodes, errors } = ngHtmlParser.parse(data);
129
+ if (errors.length) return;
130
+
131
+ parseTemplateNode(rootNodes);
132
+ _.each(TXComponents, (txcmp) => {
133
+ let string = '';
134
+ let key = '';
135
+ const params = {};
136
+ if (txcmp.attrs) {
137
+ _.each(txcmp.attrs, (attribute) => {
138
+ if (attribute.name === 'str') {
139
+ string = attribute.value;
140
+ } else if (attribute.name === 'key') {
141
+ key = attribute.value;
142
+ } else {
143
+ params[attribute.name] = attribute.value;
144
+ }
145
+ });
146
+ }
147
+ if (string) {
148
+ const partial = createPayload(string, params, relativeFile, options);
149
+ if (!isPayloadValid(partial, options)) return;
150
+
151
+ mergePayload(HASHES, {
152
+ [key || partial.key]: {
153
+ string: partial.string,
154
+ meta: partial.meta,
155
+ },
156
+ });
157
+ }
158
+ });
159
+
160
+ _.each(TXTemplateStrings, (txStr) => {
161
+ let key = '';
162
+
163
+ if (txStr.params.key) {
164
+ key = txStr.params.key;
165
+ }
166
+
167
+ const partial = createPayload(txStr.string, txStr.params, relativeFile, options);
168
+ if (!isPayloadValid(partial, options)) return;
169
+
170
+ mergePayload(HASHES, {
171
+ [key || partial.key]: {
172
+ string: partial.string,
173
+ meta: partial.meta,
174
+ },
175
+ });
176
+ });
177
+ }
178
+
179
+ module.exports = {
180
+ parseHTMLTemplateFile,
181
+ };
@@ -0,0 +1,161 @@
1
+ const babelParser = require('@babel/parser');
2
+ const babelTraverse = require('@babel/traverse').default;
3
+ const _ = require('lodash');
4
+ const { mergePayload } = require('../merge');
5
+ const {
6
+ isTransifexCall, findDeclaredValue, createPayload, isPayloadValid,
7
+ } = require('./utils');
8
+
9
+ function babelParse(source) {
10
+ try {
11
+ return babelParser.parse(
12
+ source,
13
+ {
14
+ sourceType: 'unambiguous',
15
+ plugins: [
16
+ 'decorators-legacy',
17
+ 'classProperties',
18
+ 'jsx',
19
+ 'typescript',
20
+ ],
21
+ },
22
+ );
23
+ } catch (e) {
24
+ return babelParser.parse(
25
+ source,
26
+ {
27
+ sourceType: 'unambiguous',
28
+ plugins: [
29
+ 'decorators-legacy',
30
+ 'jsx',
31
+ 'flow',
32
+ ],
33
+ },
34
+ );
35
+ }
36
+ }
37
+
38
+ function babelExtractPhrases(HASHES, source, relativeFile, options) {
39
+ const ast = babelParse(source);
40
+ babelTraverse(ast, {
41
+ // T / UT functions
42
+ CallExpression({ node, scope }) {
43
+ // Check if node is a Transifex function
44
+ if (!isTransifexCall(node)) return;
45
+ if (_.isEmpty(node.arguments)) return;
46
+
47
+ // Try to find the value of first argument
48
+
49
+ const string = findDeclaredValue(scope, node.arguments[0]);
50
+
51
+ // Verify that at least the string is passed to the function
52
+ if (!_.isString(string)) return;
53
+
54
+ // Extract function parameters
55
+ const params = {};
56
+ if (
57
+ node.arguments[1]
58
+ && node.arguments[1].type === 'ObjectExpression'
59
+ ) {
60
+ _.each(node.arguments[1].properties, (prop) => {
61
+ // get only string on number params
62
+ const value = findDeclaredValue(scope, prop.value);
63
+ if (_.isString(value) || _.isNumber(value)) {
64
+ params[prop.key.name] = value;
65
+ }
66
+ });
67
+ }
68
+
69
+ const partial = createPayload(string, params, relativeFile, options);
70
+ if (!isPayloadValid(partial, options)) return;
71
+
72
+ mergePayload(HASHES, {
73
+ [partial.key]: {
74
+ string: partial.string,
75
+ meta: partial.meta,
76
+ },
77
+ });
78
+ },
79
+
80
+ // Supported SDK Decorators
81
+ Decorator({ node }) {
82
+ const elem = node.expression;
83
+
84
+ if (!elem || !elem.arguments || !elem.arguments.length) return;
85
+
86
+ // Angular SDK T Decorator
87
+ if (node && node.expression && node.expression.callee
88
+ && node.expression.callee.name === 'T') {
89
+ let string = '';
90
+ let key = '';
91
+ const params = {};
92
+ _.each(node.expression.arguments, (arg) => {
93
+ if (arg.type === 'StringLiteral') {
94
+ string = arg.value;
95
+ } else if (arg.type === 'ObjectExpression') {
96
+ _.each(arg.properties, (prop) => {
97
+ if (prop.key.name === '_key') {
98
+ key = prop.value.value;
99
+ } else {
100
+ params[prop.key.name] = prop.value.value;
101
+ }
102
+ });
103
+ }
104
+ });
105
+
106
+ if (string) {
107
+ const partial = createPayload(string, params, relativeFile, options);
108
+ if (!isPayloadValid(partial, options)) return;
109
+
110
+ mergePayload(HASHES, {
111
+ [key || partial.key]: {
112
+ string: partial.string,
113
+ meta: partial.meta,
114
+ },
115
+ });
116
+ }
117
+ }
118
+ },
119
+
120
+ // React component
121
+ JSXElement({ node, scope }) {
122
+ const elem = node.openingElement;
123
+
124
+ if (!elem || !elem.name) return;
125
+ if (elem.name.name !== 'T' && elem.name.name !== 'UT') return;
126
+
127
+ let string;
128
+ const params = {};
129
+ _.each(elem.attributes, (attr) => {
130
+ const property = attr.name && attr.name.name;
131
+ if (!property || !attr.value) return;
132
+
133
+ const attrValue = findDeclaredValue(scope, attr.value);
134
+ if (!attrValue) return;
135
+
136
+ if (property === '_str') {
137
+ string = attrValue;
138
+ return;
139
+ }
140
+ params[property] = attrValue;
141
+ });
142
+
143
+ if (!string) return;
144
+
145
+ const partial = createPayload(string, params, relativeFile, options);
146
+ if (!isPayloadValid(partial, options)) return;
147
+
148
+ mergePayload(HASHES, {
149
+ [partial.key]: {
150
+ string: partial.string,
151
+ meta: partial.meta,
152
+ },
153
+ });
154
+ },
155
+ });
156
+ }
157
+
158
+ module.exports = {
159
+ babelExtractPhrases,
160
+ babelParse,
161
+ };
@@ -0,0 +1,157 @@
1
+ /* eslint no-underscore-dangle: 0 */
2
+ const _ = require('lodash');
3
+ const { generateKey, generateHashedKey } = require('@transifex/native');
4
+ const { stringToArray, mergeArrays } = require('../utils');
5
+ /**
6
+ * Find value bound to some identifier with passed name.
7
+ *
8
+ * @param {Object} scope AST Scope to use for lookup.
9
+ * @param {String} name Name of the identifier.
10
+ * @returns {String?} declared value or null.
11
+ */
12
+ function findIdentifierValue(scope, name) {
13
+ if (!scope) return null;
14
+
15
+ if (scope.bindings[name]) {
16
+ const binding = scope.bindings[name];
17
+
18
+ if (binding.kind !== 'const') return null;
19
+ const { node } = binding.path;
20
+
21
+ if (node.type === 'VariableDeclarator' && node.init) {
22
+ // eslint-disable-next-line no-use-before-define
23
+ return findDeclaredValue(scope, node.init);
24
+ }
25
+ }
26
+
27
+ if (scope.path.parentPath) {
28
+ return findIdentifierValue(scope.path.parentPath.scope, name);
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Find declared value bound to identifier defined in init.
36
+ *
37
+ * @param {Object} scope AST Scope to use for lookup.
38
+ * @param {Object} init AST Node to work with.
39
+ * @returns {String?} declared value or null.
40
+ */
41
+ function findDeclaredValue(scope, init) {
42
+ if (!init) return null;
43
+
44
+ if (init.type === 'StringLiteral') {
45
+ return init.value;
46
+ }
47
+
48
+ if (init.type === 'NumericLiteral') {
49
+ return init.value;
50
+ }
51
+
52
+ if (init.type === 'JSXExpressionContainer') {
53
+ return findDeclaredValue(scope, init.expression);
54
+ }
55
+
56
+ if (init.type === 'Identifier') {
57
+ return findIdentifierValue(scope, init.name);
58
+ }
59
+
60
+ if (init.type === 'BinaryExpression' && init.operator === '+') {
61
+ const left = findDeclaredValue(scope, init.left);
62
+ const right = findDeclaredValue(scope, init.right);
63
+
64
+ if (_.isString(left) && _.isString(right)) {
65
+ return left + right;
66
+ }
67
+ }
68
+
69
+ if (init.type === 'TemplateLiteral') {
70
+ const expressions = init.expressions.map((node) => findDeclaredValue(scope, node));
71
+ if (expressions.includes(null)) return null;
72
+
73
+ const elements = init.quasis.flatMap(
74
+ ({ tail, value }, i) => (tail ? value.raw : [value.raw, expressions[i]]),
75
+ );
76
+ return elements.join('');
77
+ }
78
+
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * Check if payload coming from createPayload is valid based on tag filters
84
+ *
85
+ * @param {Object} payload
86
+ * @param {String[]} options.filterWithTags
87
+ * @param {String[]} options.filterWithoutTags
88
+ * @returns {Boolean}
89
+ */
90
+ function isPayloadValid(payload, options = {}) {
91
+ const { filterWithTags, filterWithoutTags } = options;
92
+ let isValid = true;
93
+ _.each(filterWithTags, (tag) => {
94
+ if (!_.includes(payload.meta.tags, tag)) {
95
+ isValid = false;
96
+ }
97
+ });
98
+ _.each(filterWithoutTags, (tag) => {
99
+ if (_.includes(payload.meta.tags, tag)) {
100
+ isValid = false;
101
+ }
102
+ });
103
+ return isValid;
104
+ }
105
+
106
+ /**
107
+ * Check if callee is a valid Transifex Native function
108
+ *
109
+ * @param {*} node
110
+ * @returns {Boolean}
111
+ */
112
+ function isTransifexCall(node) {
113
+ const { callee } = node;
114
+ if (!callee) return false;
115
+ if (_.includes(['t', 'useT', '$t'], callee.name)) { return true; }
116
+ if (!callee.object || !callee.property) return false;
117
+ if (callee.property.name === 'translate') return true;
118
+ return false;
119
+ }
120
+
121
+ /**
122
+ * Create an extraction payload
123
+ *
124
+ * @param {String} string
125
+ * @param {Object} params
126
+ * @param {String} params._context
127
+ * @param {String} params._comment
128
+ * @param {Number} params._charlimit
129
+ * @param {Number} params._tags
130
+ * @param {String} occurence
131
+ * @param {Object} options
132
+ */
133
+ function createPayload(string, params, occurence, options = {}) {
134
+ return {
135
+ string,
136
+ key: options.useHashedKeys
137
+ ? generateHashedKey(string, params)
138
+ : generateKey(string, params),
139
+ meta: _.omitBy({
140
+ context: stringToArray(params._context || params.context),
141
+ developer_comment: params._comment || params.comment,
142
+ character_limit: params._charlimit || params.charlimit
143
+ ? parseInt(params._charlimit || params.charlimit, 10)
144
+ : undefined,
145
+ tags: mergeArrays(stringToArray(params._tags || params.tags), options.appendTags),
146
+ occurrences: [occurence],
147
+ }, _.isNil),
148
+ };
149
+ }
150
+
151
+ module.exports = {
152
+ findIdentifierValue,
153
+ findDeclaredValue,
154
+ isPayloadValid,
155
+ isTransifexCall,
156
+ createPayload,
157
+ };
@@ -0,0 +1,133 @@
1
+ const _ = require('lodash');
2
+ const vueTemplateCompiler = require('vue-template-compiler');
3
+ const { createPayload, isPayloadValid } = require('./utils');
4
+ const { mergePayload } = require('../merge');
5
+ const { babelExtractPhrases } = require('./babel');
6
+
7
+ /**
8
+ * A function to traverse the AST provided by the vue-template-compiler package
9
+ *
10
+ * @param {*} ast
11
+ * @param {*} visitor
12
+ */
13
+ function traverseVueTemplateAst(ast, visitor = {}) {
14
+ // Use this in order to identify expressions in the AST since there is no
15
+ // Tag to identify it with when traversing
16
+ const VISITORS = {
17
+ 2: 'Expression',
18
+ };
19
+
20
+ function traverseArray(array, parent) {
21
+ array.forEach((child) => {
22
+ // eslint-disable-next-line no-use-before-define
23
+ traverseNode(child, parent);
24
+ });
25
+ }
26
+
27
+ function traverseNode(node, parent) {
28
+ if (visitor.enter) visitor.enter(node, parent);
29
+ if (visitor[node.tag]) visitor[node.tag](node, parent);
30
+ // Take care of expressions
31
+ if (visitor[VISITORS[node.type]] && node.expression) {
32
+ visitor[VISITORS[node.type]](node, parent);
33
+ }
34
+ if (node.children) traverseArray(node.children, node);
35
+ if (visitor.exit) visitor.exit(node, parent);
36
+ }
37
+ traverseNode(ast, null);
38
+ }
39
+
40
+ /**
41
+ * Extracted function for the T/UT components to use as a visitor for the
42
+ * template AST traverse.
43
+ *
44
+ * @param {Object} HASHES A map of keys and content for phrases
45
+ * @param {String} relativeFile occurence
46
+ * @param {Object} options
47
+ * @param {String[]} options.appendTags
48
+ * @param {String[]} options.filterWithTags
49
+ * @param {String[]} options.filterWithoutTags
50
+ * @param {Boolean} options.useHashedKeys
51
+ * @returns {Function}
52
+ */
53
+ function vueElementVisitor(HASHES, relativeFile, options) {
54
+ return (node) => {
55
+ let string;
56
+ const params = {};
57
+ _.each(node.attrsList, (attr) => {
58
+ const property = attr.name;
59
+ if (!property || !attr.value) return;
60
+
61
+ const attrValue = attr.value;
62
+
63
+ if (!attrValue) return;
64
+
65
+ if (property === '_str') {
66
+ string = attrValue;
67
+ return;
68
+ }
69
+ params[property] = attrValue;
70
+ });
71
+
72
+ if (!string) return;
73
+
74
+ const partial = createPayload(string, params, relativeFile, options);
75
+ if (!isPayloadValid(partial, options)) return;
76
+
77
+ mergePayload(HASHES, {
78
+ [partial.key]: {
79
+ string: partial.string,
80
+ meta: partial.meta,
81
+ },
82
+ });
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Parses the source with vue-template-compiler package and breaks the content
88
+ * to "template" and "script".
89
+ *
90
+ * The script gets passed to babel as usual to get the phrases and the template
91
+ * goes through compilation again to get the AST and a custom traverse
92
+ * function in order to get the phrases back.
93
+ *
94
+ * @param {Object} HASHES A map of keys and content for phrases
95
+ * @param {String} source The content we want to parse
96
+ * @param {String} relativeFile occurence
97
+ * @param {Object} options
98
+ * @param {String[]} options.appendTags
99
+ * @param {String[]} options.filterWithTags
100
+ * @param {String[]} options.filterWithoutTags
101
+ * @param {Boolean} options.useHashedKeys
102
+ */
103
+ function extractVuePhrases(HASHES, source, relativeFile, options) {
104
+ // Use the vue-template-compiler API to parse content
105
+ const vueContent = vueTemplateCompiler.parseComponent(source);
106
+
107
+ // Get the JS Content from the file and extract hashes/phrases with Babel
108
+ if (vueContent.script && vueContent.script.content) {
109
+ const script = vueContent.script.content;
110
+ babelExtractPhrases(HASHES, script, relativeFile, options);
111
+ }
112
+
113
+ // Get the template content from the file and extract hashes/phrases with
114
+ // custom traverse function
115
+ if (vueContent.template && vueContent.template.content) {
116
+ // Compile to get the AST
117
+ const template = vueTemplateCompiler.compile(vueContent.template.content, {
118
+ preserveWhitespace: false,
119
+ });
120
+
121
+ traverseVueTemplateAst(template.ast, {
122
+ Expression(node) {
123
+ babelExtractPhrases(HASHES, node.expression, relativeFile, options);
124
+ },
125
+ T: vueElementVisitor(HASHES, relativeFile, options),
126
+ UT: vueElementVisitor(HASHES, relativeFile, options),
127
+ });
128
+ }
129
+ }
130
+
131
+ module.exports = {
132
+ extractVuePhrases,
133
+ };
@@ -38,7 +38,7 @@ class PushCommand extends Command {
38
38
  : path.join(pwd, args.pattern);
39
39
 
40
40
  if (isFolder(filePattern)) {
41
- filePattern = path.join(filePattern, '**/*.{js,jsx,ts,tsx,html}');
41
+ filePattern = path.join(filePattern, '**/*.{js,jsx,ts,tsx,html,vue}');
42
42
  }
43
43
 
44
44
  const appendTags = stringToArray(flags['append-tags']);
@@ -108,7 +108,11 @@ class PushCommand extends Command {
108
108
  if (_.isEmpty(data)) return;
109
109
  this.log(file.green);
110
110
  _.each(data, (value, key) => {
111
- this.log(` └─ ${key}: ${value.string.underline}`);
111
+ if (key !== value.string) {
112
+ this.log(` └─ ${key}: ${value.string.underline}`);
113
+ } else {
114
+ this.log(` └─ ${value.string.underline}`);
115
+ }
112
116
  _.each(value.meta, (meta, metaKey) => {
113
117
  if ((_.isObject(meta) || _.isArray(meta)) && _.isEmpty(meta)) {
114
118
  return;