handlebars-i18n-cli 1.0.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.
@@ -0,0 +1,561 @@
1
+ /*********************************************************************
2
+ * i18n-collect.js
3
+ *
4
+ * @author: Florian Walzel
5
+ * @date: 2022-10
6
+ *
7
+ * Usage:
8
+ * $ i18n-collect <source> <target> <options...>
9
+ *
10
+ * valid options:
11
+ * --alphabetical || -a
12
+ * --dryRun || -dr
13
+ * --empty || -e
14
+ * --lng=de,fr,es,etc…
15
+ * --log || -l
16
+ * --separateLngFiles || -sf
17
+ * --translFunc=yourCustomFunctionName
18
+ * --update || -u*
19
+ *
20
+ * Copyright (c) 2020 Florian Walzel, MIT LICENSE
21
+ *
22
+ * Permission is hereby granted, free of charge, to any person
23
+ * obtaininga copy of this software and associated documentation
24
+ * files (the "Software"), to deal in the Software without restriction,
25
+ * including without limitation the rights to use, copy, modify, merge,
26
+ * publish, distribute, sublicense, and/or sell copies of the Software,
27
+ * and to permit persons to whom the Software is furnished to do so,
28
+ * subject to the following conditions:
29
+ *
30
+ * The above copyright notice and this permission notice shall be
31
+ * included in all copies or substantial portions of the Software.
32
+ *
33
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
34
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
35
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
36
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
37
+ * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
38
+ * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
39
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
40
+ * THE SOFTWARE.
41
+ *
42
+ *********************************************************************/
43
+
44
+
45
+ 'use strict';
46
+
47
+ /****************************************
48
+ * REQUIRE & DEFINE
49
+ ****************************************/
50
+
51
+ const fs = require('fs')
52
+ const { promisify } = require('util')
53
+ const readFileAsync = promisify(fs.readFile)
54
+ const writeFileAsync = promisify(fs.writeFile)
55
+
56
+
57
+ /****************************************
58
+ * FUNCTIONS
59
+ ****************************************/
60
+
61
+ /**
62
+ * Asynchronously read file
63
+ *
64
+ * @param file
65
+ * @returns {Promise<*>}
66
+ */
67
+ async function readFile (file) {
68
+ try {
69
+ const data = await readFileAsync(file, 'utf8')
70
+ return data
71
+ }
72
+ catch (e) {
73
+ console.log('\x1b[31m%s\x1b[0m', `Error. Could not read ${file}`)
74
+ console.error(e)
75
+ return false
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Asynchronously write file in utf8 encoding
81
+ *
82
+ * @param file
83
+ * @param data
84
+ * @returns {Promise<boolean>}
85
+ */
86
+ async function writeFile(file, data) {
87
+ try {
88
+ await writeFileAsync(file, data, 'utf8')
89
+ return true
90
+ }
91
+ catch (e) {
92
+ console.log('\x1b[31m%s\x1b[0m', `Error. Could not write ${file}`)
93
+ console.error(e)
94
+ return false
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Simple object check.
100
+ *
101
+ * @param item
102
+ * @returns {boolean}
103
+ */
104
+ function isObject(item) {
105
+ return (item && typeof item === 'object' && ! Array.isArray(item))
106
+ }
107
+
108
+ /**
109
+ * Conditionally removes a substring (file extension)
110
+ * from end of given string.
111
+ *
112
+ * @param str
113
+ * @param ending
114
+ * @returns {*|string[]}
115
+ */
116
+ function sanitizeFileExt(str, ending='.json') {
117
+ return str.toLowerCase().endsWith(ending) ? str.slice(0, ending.length * (-1)) : str
118
+ }
119
+
120
+ /**
121
+ * Log the help information to console
122
+ * @returns {boolean}
123
+ */
124
+ function logHelp() {
125
+ console.log('\x1b[2m%s', 'Usage:')
126
+ console.log('i18n-collect <source> <target> <options...>')
127
+ console.log('')
128
+ console.log('<source> path to handlebars.js template file(s), glob pattern allowed')
129
+ console.log('<target> json file(s) to write result to')
130
+ console.log('')
131
+ console.log('<options>')
132
+ console.log('--alphabetical or -a will order the keys to the translation strings alphabetically in the json')
133
+ console.log(' default: keys in order of appearance as within the template(s)')
134
+ console.log('--dryRun or -dr will log the result(s) but not write out json file(s)')
135
+ console.log('--empty or -e will create empty value strings for the translations in the json')
136
+ console.log(' default: value strings contain current language and key name')
137
+ console.log('--lng=en,fr,es,… the languages you want to be generated')
138
+ console.log(' default: en')
139
+ console.log('--log or -l log final results to console')
140
+ console.log('--separateLngFiles write each language in a separate json file')
141
+ console.log(' or -sf default: all languages are written as arrays in one json file')
142
+ console.log('--translFunc=customName a custom name of the translation function used in the templates')
143
+ console.log(' default: __ like handlebars-i18n notation: {{__ keyToTranslate}}')
144
+ console.log('--update or -u updates existing json files(s) after changes made in template file(s)')
145
+ console.log('\x1b[0m')
146
+ return true
147
+ }
148
+
149
+ /**
150
+ * A collection of functions to extract and handle the
151
+ * strings between mustaches {{ ... }}
152
+ *
153
+ * @type {{str: string,
154
+ * removeFromBetween: ((function(*, *): boolean)|*),
155
+ * getSorted: (function(*, *, *=, *=): []),
156
+ * results: *[], getFromBetween: ((function(*, *): (string|boolean))|*),
157
+ * getAllResults: mustacheBetweens.getAllResults}}
158
+ */
159
+ const mustacheBetweens = {
160
+ results : [ ],
161
+ str : '',
162
+ /**
163
+ * Returns a substring between an opening and
164
+ * a closing string sequence in a given string
165
+ *
166
+ * @param sub1
167
+ * @param sub2
168
+ * @returns {string|boolean}
169
+ */
170
+ getFromBetween : function(sub1, sub2) {
171
+ if (this.str.indexOf(sub1) < 0 || this.str.indexOf(sub2) < 0)
172
+ return false
173
+ let SP = this.str.indexOf(sub1) + sub1.length,
174
+ string1 = this.str.substr(0, SP),
175
+ string2 = this.str.substr(SP),
176
+ TP = string1.length + string2.indexOf(sub2)
177
+ return this.str.substring(SP, TP)
178
+ },
179
+
180
+ /**
181
+ * Removes a found sequence between an opening and
182
+ * a closing string from a a given string
183
+ *
184
+ * @param sub1
185
+ * @param sub2
186
+ * @returns {boolean}
187
+ */
188
+ removeFromBetween : function(sub1, sub2) {
189
+ if (this.str.indexOf(sub1) < 0 || this.str.indexOf(sub2) < 0)
190
+ return false
191
+ let removal = sub1 + this.getFromBetween(sub1, sub2) + sub2
192
+ this.str = this.str.replace(removal,'')
193
+ },
194
+
195
+ /**
196
+ * gets all substrings of a string that are between an opening character sequence (sub1)
197
+ * and a closing sequence (sub2). The Result is stored in parent results array.
198
+ *
199
+ * @param sub1
200
+ * @param sub2
201
+ */
202
+ getAllResults : function(sub1, sub2) {
203
+ // first check to see if we do have both substrings
204
+ if (this.str.indexOf(sub1) < 0 || this.str.indexOf(sub2) < 0)
205
+ return false
206
+ // find first result
207
+ let result = this.getFromBetween(sub1, sub2)
208
+ // replace multiple spaces by a single one, then trim and push it to the results array
209
+ this.results.push(result.replace(/ +(?= )/g,'').trim())
210
+ // remove the most recently found one from the string
211
+ this.removeFromBetween(sub1, sub2)
212
+ // recursion in case there are more substrings
213
+ if (this.str.indexOf(sub1) > -1 && this.str.indexOf(sub2) > -1)
214
+ this.getAllResults(sub1, sub2)
215
+ },
216
+
217
+ /**
218
+ *
219
+ * @param string
220
+ * @param sub1
221
+ * @param sub2
222
+ * @returns {*}
223
+ */
224
+ getSorted : function(string, translFuncName, sub1='{{', sub2='}}') {
225
+ this.str = string
226
+ this.getAllResults(sub1, sub2)
227
+ this.results =
228
+ this.results.filter(
229
+ (el) => {
230
+ return typeof el === 'string' && el.startsWith(`${translFuncName} `)
231
+ })
232
+ .map(
233
+ (el) => {
234
+ // remove leading translation function and explode string by space
235
+ let splited = el.replace(`${translFuncName} `, '').split(' ')
236
+ // remove quotation marks around key name in element 0 of array
237
+ splited[0] = splited[0]
238
+ .replace(/"/g, '')
239
+ .replace(/'/g, '')
240
+ // split remaining string in first element of array by dot (.) to get separate keys of a dot-notated object
241
+ let keys = splited[0].split('.')
242
+ // transformed is a container object for key
243
+ let transformed = { }
244
+ transformed.keys = keys
245
+ transformed.replacementVars = [ ]
246
+ // split following elements by '=' and preserve first element of split
247
+ for (let i = 1; i < splited.length; i++)
248
+ transformed.replacementVars[i-1] = splited[i].split('=')[0]
249
+
250
+ return transformed
251
+ })
252
+ return this.results
253
+ }
254
+ };
255
+
256
+ /**
257
+ * Filter specific array so that all elements with redundant
258
+ * values keys are removed. Array looks like:
259
+ * [
260
+ * { keys: [ 'b' ], replacementVars: [] },
261
+ * { keys: [ 'c' ], replacementVars: [] },
262
+ * …
263
+ * ]
264
+ *
265
+ * @param arr
266
+ * @returns {*}
267
+ */
268
+ const arrRmvDuplicateValues = (arr) => {
269
+ let seen = { }
270
+ return arr.filter((item) => {
271
+ return seen.hasOwnProperty(item.keys) ? false : seen[item.keys] = true
272
+ })
273
+ }
274
+
275
+
276
+ /**
277
+ * Builds a nested object with key-value-pairs from a three-dimensional array.
278
+ *
279
+ * @param arr
280
+ * @param lang
281
+ * @param empty
282
+ * @returns {{}}
283
+ */
284
+ function objectify (arr, lang='en', empty = false) {
285
+
286
+ /**
287
+ *
288
+ * @param obj
289
+ * @param val
290
+ * @param arr
291
+ * @param pos
292
+ */
293
+ function __iterateArr (obj, val, arr, pos) {
294
+ if (! obj.hasOwnProperty(arr[pos])) {
295
+ if (pos + 1 < arr.length) {
296
+ obj[arr[pos]] = { }
297
+ __iterateArr(obj[arr[pos]], val, arr, pos + 1)
298
+ }
299
+ else
300
+ obj[arr[pos]] = val;
301
+ }
302
+ else if (pos+1 < arr.length)
303
+ __iterateArr(obj[arr[pos]], val, arr, pos + 1);
304
+ }
305
+
306
+ /**
307
+ * Return a joined string form an array, where each element is
308
+ * wrapped in Mustaches {{ }}. ["a", "b", "c"] becomes "{{a}} {{b}} {{c}}"
309
+ *
310
+ * @param arr
311
+ * @param textBefore
312
+ * @returns {string}
313
+ */
314
+ function __listTranslVariables(arr, textBefore= '') {
315
+ let str = '';
316
+ if (arr.length === 0)
317
+ return str;
318
+ for (let elem of arr)
319
+ str += `{{${elem}}} `;
320
+ return textBefore + str.slice(0, -1);
321
+ }
322
+
323
+ let obj = { }
324
+ arr.forEach((el) => {
325
+ let prop;
326
+ if (empty)
327
+ prop = __listTranslVariables(el.replacementVars);
328
+ else
329
+ prop = `${lang} of ${el.keys.join('.') + __listTranslVariables(el.replacementVars, ' with variables ')}`
330
+ __iterateArr(obj, prop, el.keys, 0)
331
+ })
332
+
333
+ return obj
334
+ }
335
+
336
+ /**
337
+ * Deep merge two objects whereby all properties of
338
+ * sources are kept and the target properties are added.
339
+ *
340
+ * @param target
341
+ * @param ...sources
342
+ */
343
+ function mergeDeep(target, ...sources) {
344
+ if (! sources.length) return target;
345
+ const source = sources.shift();
346
+
347
+ if (isObject(target) && isObject(source)) {
348
+ for (const key in source) {
349
+ if (isObject(source[key])) {
350
+ if (! target[key])
351
+ Object.assign(target, {[key]: { }});
352
+ mergeDeep(target[key], source[key]);
353
+ } else
354
+ Object.assign(target, {[key]: source[key]});
355
+ }
356
+ }
357
+
358
+ return mergeDeep(target, ...sources);
359
+ }
360
+
361
+ /**
362
+ * deep sort an array by the values stored
363
+ * in the "keys" property. Incoming array looks like this:
364
+ * [
365
+ * { keys: [ 'a' ], replacementVars: [] },
366
+ * { keys: [ 'b', 'a' ], replacementVars: [] },
367
+ * …
368
+ * ]
369
+ * @param arr
370
+ * @returns arr
371
+ */
372
+ function deepSort(arr) {
373
+ // determine the longest array in keys properties
374
+ let depth = 0;
375
+ for (let inst of arr)
376
+ if (inst.keys.length > depth)
377
+ depth = inst.keys.length;
378
+
379
+ // iterate from longest to shortest and sort
380
+ for (let i = depth-1; i>=0; i--) {
381
+ arr = arr.sort((a, b) => {
382
+ if ( a.keys[i] !== undefined && b.keys[i] !== undefined )
383
+ return a.keys[i] > b.keys[i] ? -1 : 1;
384
+ else
385
+ return a.keys[i] !== undefined ? -1 : 1
386
+ });
387
+ }
388
+ // we get a descending array, so we invert it
389
+ return arr.reverse();
390
+ }
391
+
392
+
393
+ /****************************************
394
+ * EXPORT PUBLIC INTERFACE
395
+ ****************************************/
396
+
397
+ exports.cli = async (argv) => {
398
+
399
+ // take in cli arguments from process argv
400
+ let args = [ ]
401
+ for (let i = 2; i < argv.length; i++)
402
+ args.push(argv[i])
403
+
404
+ // validation: error when no argument was given
405
+ if (args.length === 0)
406
+ throw new Error(`No arguments given. Please specify SOURCE and TARGET.
407
+ Call argument --help for further Information.`)
408
+
409
+ // only one argument was given
410
+ if (args.length === 1) {
411
+ // answer to argument 'help'
412
+ if (['help', '--help', '-h'].includes(args[0]))
413
+ return logHelp()
414
+ // error the missing second argument
415
+ else
416
+ throw new Error(`Missing second argument for TARGET.
417
+ Call argument --help for further Information.`)
418
+ }
419
+
420
+ // register vars
421
+ let hndlbrKeys = [ ],
422
+ sources,
423
+ targetFileName,
424
+ targetFileNameSeparated,
425
+ translationFuncName,
426
+ pos = -1,
427
+ languages,
428
+ translObj,
429
+ outputObj;
430
+
431
+ // create an array "sources" by filtering out all options from args
432
+ sources = args.filter(
433
+ (el) => ! (el === 'help' || el.startsWith('--') || el.startsWith('-'))
434
+ )
435
+
436
+ // then removing last element as being the <target>
437
+ targetFileName = sources.pop()
438
+
439
+ // check for argument '--translFunc=someName' => a custom function name was given
440
+ args.forEach((el, key) => {
441
+ if (el.startsWith('--translFunc=')) return pos = key
442
+ })
443
+ translationFuncName = (pos >= 0) ? args[pos].split('=')[1] : '__'
444
+
445
+ // read in file(s) and join contents keeping only unique key names
446
+ for (let file of sources) {
447
+ console.log(`Now processing ${file}`)
448
+ let content = await readFile(file)
449
+ hndlbrKeys = hndlbrKeys.concat(
450
+ mustacheBetweens.getSorted(content, translationFuncName)
451
+ )
452
+ }
453
+
454
+ // break if no strings for translation where found
455
+ if (hndlbrKeys.length === 0)
456
+ return console.log('No strings for translation found, no files written.')
457
+
458
+ // remove all duplicate value entries in position 'keys' of array hndlbrKeys
459
+ hndlbrKeys = arrRmvDuplicateValues(hndlbrKeys)
460
+
461
+ // evaluate argument '--alphabetical' for sorting
462
+ if (args.includes('--alphabetical') || args.includes('-a'))
463
+ hndlbrKeys = deepSort(hndlbrKeys)
464
+
465
+ // form an array of languages from argument '--lng='
466
+ languages = args.filter((el) => {
467
+ return el.startsWith('--lng=')
468
+ }).map((el) => {
469
+ return el.split('=')[1].split(',')
470
+ })[0]
471
+
472
+ // if no language parameter is passed set 'en' as default language
473
+ if (typeof languages === 'undefined') languages = ['en']
474
+
475
+
476
+ // WRITE TO ONE FILE PER LANGUAGE
477
+ // ------------------------------------------------
478
+
479
+ // evaluate argument '--separateLngFiles' to output each language in a separate file
480
+ if (args.includes('--separateLngFiles') || args.includes('-sf')) {
481
+
482
+ // if user entered argument for target ending with .json, remove it
483
+ targetFileName = sanitizeFileExt(targetFileName)
484
+
485
+ for (let lng of languages) {
486
+ // join file name per language such as myfile.de.json, myfile.en.json, ...
487
+ targetFileNameSeparated =
488
+ (targetFileName.startsWith('/') ? targetFileName.substring(1) : targetFileName) + '.' + lng + '.json'
489
+
490
+ // create output object per language and add keys in nested object form
491
+ outputObj = { }
492
+ outputObj[lng] = objectify(hndlbrKeys, lng, args.includes('--empty') || args.includes('-e'))
493
+
494
+ // if argument '--update' was given, existing files per language are read in, parsed,
495
+ // and the new translation Object is merged onto the existing translation
496
+ if (args.includes('--update') || args.includes('-u')) {
497
+ let existingTransl = await readFile(targetFileNameSeparated)
498
+ existingTransl = JSON.parse(existingTransl)
499
+ outputObj = mergeDeep(outputObj[lng], existingTransl)
500
+ }
501
+
502
+ // convert output object to json with linebreaks and indenting of 2 spaces
503
+ const fileOutputJson = JSON.stringify(outputObj, null, 2)
504
+
505
+ // log output per language
506
+ if (args.includes('--log') || args.includes('-l')
507
+ || args.includes('--dryRun') || args.includes('-dr'))
508
+ console.log(fileOutputJson)
509
+
510
+ // write files only if no --dryRun option was set
511
+ if (! args.includes('--dryRun') && ! args.includes('-dr'))
512
+ // write out the json to target file per language
513
+ if (await writeFile(targetFileNameSeparated, fileOutputJson))
514
+ console.log('\x1b[34m%s\x1b[0m', `Wrote language keys for '${lng}' to ${targetFileNameSeparated}`)
515
+ }
516
+
517
+ return (args.includes('--dryRun') || args.includes('-dr')) ?
518
+ console.log('\x1b[36m%s\x1b[0m', 'This was a dry run. No files witten.')
519
+ :
520
+ console.log('\x1b[32m%s\x1b[0m', `You’re good. All Done.`)
521
+ }
522
+
523
+ // WRITE SINGLE FILE CONTAINING ALL LANGUAGES
524
+ // ------------------------------------------------
525
+ else {
526
+ // create object to hold the translations and create a key for every language
527
+ // add all handlebars translation keys to each language key as nested objects
528
+ translObj = {translations: { }}
529
+ languages.forEach((lng) => {
530
+ translObj.translations[lng] = objectify(hndlbrKeys, lng, args.includes('--empty') || args.includes('-e'))
531
+ })
532
+
533
+ // if argument '--update' was given, an existing file is read in, parsed,
534
+ // and the new translation Object is merged onto the existing translations
535
+ if (args.includes('--update') || args.includes('-u')) {
536
+ let existingTransl = await readFile(targetFileName)
537
+ existingTransl = JSON.parse(existingTransl)
538
+ outputObj = mergeDeep(translObj, existingTransl)
539
+ }
540
+ else
541
+ outputObj = translObj
542
+
543
+ // convert output object to json with linebreaks and indenting of 2 spaces
544
+ const fileOutputJson = JSON.stringify(outputObj,null, 2)
545
+
546
+ // log the final object to console if option '--log' or '--dryRun' was set
547
+ if (args.includes('--log') || args.includes('-l')
548
+ || args.includes('--dryRun') || args.includes('-dr'))
549
+ console.log(fileOutputJson)
550
+
551
+ // exit if option '--dryRun' was set
552
+ if (args.includes('--dryRun') || args.includes('-dr')) {
553
+ console.log('\x1b[36m%s\x1b[0m', 'This was a dry run. No file witten.')
554
+ process.exit(0)
555
+ }
556
+
557
+ // write out the json to target file
558
+ if (await writeFile(targetFileName, fileOutputJson))
559
+ return console.log('\x1b[32m%s\x1b[0m', `Done and Ready! Your output was written to ${targetFileName}`)
560
+ }
561
+ }