-
Notifications
You must be signed in to change notification settings - Fork 36
/
file.js
553 lines (534 loc) · 22 KB
/
file.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
/* eslint-disable no-control-regex */
'use strict';
const TYPE = require('../../types/mcdev.d');
const fs = require('fs-extra');
const packageJson = require('../../package.json');
const path = require('node:path');
const prettier = require('prettier');
const beautyAmp = require('beauty-amp-core');
const Util = require('./util');
const updateNotifier = require('update-notifier');
// inform user when there is an update
const notifier = updateNotifier({
pkg: packageJson,
updateCheckInterval: 1000 * 3600 * 24, // once per day
});
// Notify using the built-in convenience method
notifier.notify();
/**
* File extends fs-extra. It adds logger and util methods for file handling
*/
const File = {
/**
* copies a file from one path to another
*
* @param {string} from - full filepath including name of existing file
* @param {string} to - full filepath including name where file should go
* @returns {object} - results object
*/
async copyFile(from, to) {
try {
await fs.copy(from, to);
return { status: 'ok', file: from };
} catch (ex) {
// This can happen in some cases where referencing files deleted in Commit
return ex.message.startsWith('ENOENT: no such file or directory')
? {
status: 'skipped',
statusMessage: 'deleted from repository',
file: from,
}
: { status: 'failed', statusMessage: ex.message, file: from };
}
},
/**
* makes sure Windows accepts path names
*
* @param {string} path - filename or path
* @returns {string} - corrected string
*/
filterIllegalPathChars(path) {
return (
encodeURIComponent(path)
.replaceAll(/[*]/g, '_STAR_')
// convert space back
.split('%20')
.join(' ')
// convert forward slash back as it's needed in dirs
.split('%2F')
.join('/')
// convert backward slash back as it's needed in dirs
.split('%5C')
.join('\\')
// convert opening-curly brackets back for templating
.split('%7B')
.join('{')
// convert closing-curly brackets back for templating
.split('%7D')
.join('}')
// convert brackets back for asset blocks
.split('%5B')
.join('[')
// convert brackets back for asset blocks
.split('%5D')
.join(']')
// convert @ back for users
.split('%40')
.join('@')
);
},
/**
* makes sure Windows accepts file names
*
* @param {string} filename - filename or path
* @returns {string} - corrected string
*/
filterIllegalFilenames(filename) {
return (
encodeURIComponent(filename)
.replaceAll(/[*]/g, '_STAR_')
// convert space back
.split('%20')
.join(' ')
// convert opening-curly brackets back for templating
.split('%7B')
.join('{')
// convert closing-curly brackets back for templating
.split('%7D')
.join('}')
// convert brackets back for asset blocks
.split('%5B')
.join('[')
// convert brackets back for asset blocks
.split('%5D')
.join(']')
// convert @ back for users
.split('%40')
.join('@')
);
},
/**
* makes sure Windows accepts file names
*
* @param {string} filename - filename or path
* @returns {string} - corrected string
*/
reverseFilterIllegalFilenames(filename) {
return decodeURIComponent(filename).split('_STAR_').join('*');
},
/**
* Takes various types of path strings and formats into a platform specific path
*
* @param {string|string[]} denormalizedPath directory the file will be written to
* @returns {string} Path strings
*/
normalizePath: function (denormalizedPath) {
/* eslint-disable unicorn/prefer-ternary */
if (Array.isArray(denormalizedPath)) {
// if the value is undefined set to empty string to allow parsing
return path.join(...denormalizedPath.map((val) => val || ''));
} else {
// if directory is empty put . as otherwill will write to c://
return path.join(denormalizedPath || '.');
}
/* eslint-enable unicorn/prefer-ternary */
},
/**
* Saves json content to a file in the local file system. Will create the parent directory if it does not exist
*
* @param {string|string[]} directory directory the file will be written to
* @param {string} filename name of the file without '.json' ending
* @param {object} content filecontent
* @returns {Promise} Promise
*/
writeJSONToFile: async function (directory, filename, content) {
directory = this.filterIllegalPathChars(this.normalizePath(directory));
filename = this.filterIllegalFilenames(filename);
await fs.ensureDir(directory);
try {
return fs.writeJSON(path.join(directory, filename + '.json'), content, { spaces: 4 });
} catch (ex) {
Util.logger.error('File.writeJSONToFile:: error | ' + ex.message);
}
},
/**
* Saves beautified files in the local file system. Will create the parent directory if it does not exist
* ! Important: run 'await File.initPrettier()' in your MetadataType.retrieve() once before hitting this
*
* @param {string|string[]} directory directory the file will be written to
* @param {string} filename name of the file without suffix
* @param {string} filetype filetype ie. JSON or SSJS
* @param {string} content filecontent
* @param {TYPE.TemplateMap} [templateVariables] templating variables to be replaced in the metadata
* @returns {Promise.<boolean>} Promise
*/
writePrettyToFile: async function (directory, filename, filetype, content, templateVariables) {
let formatted =
filetype === 'amp'
? this._beautify_beautyAmp(content)
: await this._beautify_prettier(directory, filename, filetype, content);
if (templateVariables) {
formatted = Util.replaceByObject(formatted, templateVariables);
}
return this.writeToFile(directory, filename, filetype, formatted);
},
/**
* helper for {@link File.writePrettyToFile}, applying beautyAmp onto given stringified content
*
* @param {string} content filecontent
* @returns {string} original string on error; formatted string on success
*/
_beautify_beautyAmp: function (content) {
// immutable at the moment:
const ampscript = {
capitalizeAndOrNot: true,
capitalizeIfFor: true,
capitalizeSet: true,
capitalizeVar: true,
maxParametersPerLine: 4,
};
// immutable at the moment:
const editor = {
insertSpaces: true,
tabSize: 4,
};
// logs trough console only for the moment.
const logs = {
loggerOn: false, // <= disable logging
};
try {
beautyAmp.setup(ampscript, editor, logs);
return beautyAmp.beautify(content);
} catch (ex) {
Util.logger.debug('File._beautify_beautyAmp:: error | ' + ex.message);
return content;
}
},
/**
* helper for {@link File.writePrettyToFile}, applying prettier onto given stringified content
* ! Important: run 'await File.initPrettier()' in your MetadataType.retrieve() once before hitting this
*
* @param {string|string[]} directory directory the file will be written to
* @param {string} filename name of the file without suffix
* @param {string} filetype filetype ie. JSON or SSJS
* @param {string} content filecontent
* @returns {string} original string on error; formatted string on success
*/
_beautify_prettier: function (directory, filename, filetype, content) {
let formatted = '';
try {
if (!FileFs.prettierConfig) {
// either no prettier config in project directory or initPrettier was not run before this
return content;
} else if (content.includes('%%[') || content.includes('%%=')) {
// in case we find AMPScript we need to abort beautifying as prettier
// will throw an error falsely assuming bad syntax
return this._beautify_beautyAmp(content);
}
// load the right prettier config relative to our file
switch (filetype) {
case 'htm':
case 'html': {
FileFs.prettierConfig.parser = 'html';
break;
}
case 'ssjs':
case 'js': {
FileFs.prettierConfig.parser = 'babel';
break;
}
case 'json': {
FileFs.prettierConfig.parser = 'json';
break;
}
case 'yaml':
case 'yml': {
FileFs.prettierConfig.parser = 'yaml';
break;
}
case 'ts': {
FileFs.prettierConfig.parser = 'babel-ts';
break;
}
case 'css': {
FileFs.prettierConfig.parser = 'css';
break;
}
case 'less': {
FileFs.prettierConfig.parser = 'less';
break;
}
case 'sass':
case 'scss': {
FileFs.prettierConfig.parser = 'scss';
break;
}
case 'md': {
FileFs.prettierConfig.parser = 'markdown';
break;
}
case 'sql': {
FileFs.prettierConfig.parser = 'sql';
break;
}
default: {
FileFs.prettierConfig.parser = 'babel';
}
}
formatted = prettier.format(content, FileFs.prettierConfig);
} catch (ex) {
const warnMsg = `Potential Code issue found in ${this.normalizePath([
...directory,
filename + '.' + filetype,
])}`;
Util.logger.debug(warnMsg);
if (Util.logger.level === 'debug') {
// useful when running test cases in which we cannot see .error files
Util.logger.debug(ex.message);
}
// save prettier errror into log file
// Note: we have to filter color codes from prettier's error message before saving it to file
this.writeToFile(
directory,
filename + '.error',
'log',
`Error Log\nParser: ${FileFs.prettierConfig.parser}\n${ex.message.replaceAll(
/[\u001B\u009B][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
''
)}`
);
formatted = content;
}
return formatted;
},
/**
* Saves text content to a file in the local file system. Will create the parent directory if it does not exist
*
* @param {string|string[]} directory directory the file will be written to
* @param {string} filename name of the file without '.json' ending
* @param {string} filetype filetype suffix
* @param {string} content filecontent
* @param {object} [encoding] added for certain file types (like images)
* @returns {Promise.<boolean>} Promise
*/
writeToFile: async function (directory, filename, filetype, content, encoding) {
directory = this.filterIllegalPathChars(this.normalizePath(directory));
await fs.ensureDir(directory);
// filter characters that are illegal for file names in Windows
filename = this.filterIllegalFilenames(filename);
const filePath = path.join(directory, filename + '.' + filetype);
try {
if (await fs.pathExists(filePath)) {
Util.logger.debug(`Overwriting: ${filePath}`);
}
await fs.writeFile(filePath, content, encoding);
return true;
} catch (ex) {
Util.logger.error('File.writeToFile:: error | ' + ex.message);
return false;
}
},
/**
* Saves json content to a file in the local file system. Will create the parent directory if it does not exist
*
* @param {string | string[]} directory directory where the file is stored
* @param {string} filename name of the file without '.json' ending
* @param {boolean} sync should execute sync (default is async)
* @param {boolean} cleanPath should execute sync (default is true)
* @returns {Promise.<object> | object | void} Promise or JSON object depending on if async or not; void on error
*/
readJSONFile: function (directory, filename, sync, cleanPath) {
try {
if (cleanPath == null || cleanPath == true) {
directory = this.filterIllegalPathChars(this.normalizePath(directory));
filename = this.filterIllegalFilenames(filename);
} else {
directory = this.normalizePath(directory);
}
if (filename.endsWith('.json')) {
filename = filename.slice(0, -5);
}
let json;
try {
json = sync
? fs.readJsonSync(path.join(directory, filename + '.json'))
: fs.readJson(path.join(directory, filename + '.json'));
} catch (ex) {
// Note: this only works for sync, not async
Util.logger.debug(ex.stack);
throw new Error(`${ex.code}: ${ex.message}`);
}
return json;
} catch (ex) {
Util.logger.error('File.readJSONFile:: error | ' + ex.message);
}
},
/**
* reads file from local file system.
*
* @param {string | string[]} directory directory where the file is stored
* @param {string} filename name of the file without '.json' ending
* @param {string} filetype filetype suffix
* @param {string} [encoding='utf8'] read file with encoding (defaults to utf-8)
* @returns {Promise.<string> | void} file contents; void on error
*/
readFilteredFilename: function (directory, filename, filetype, encoding) {
try {
directory = this.filterIllegalPathChars(this.normalizePath(directory));
filename = this.filterIllegalFilenames(filename);
return fs.readFile(path.join(directory, filename + '.' + filetype), encoding || 'utf8');
} catch (ex) {
Util.logger.error('File.readFilteredFilename:: error | ' + ex.message);
}
},
/**
* reads directories to a specific depth returning an array
* of file paths to be iterated over
*
* @example ['deploy/mcdev/bu1']
* @param {string} directory directory to checkin
* @param {number} depth how many levels to check (1 base)
* @param {boolean} [includeStem] include the parent directory in the response
* @param {number} [_stemLength] set recursively for subfolders. do not set manually!
* @returns {Promise.<string[]>} array of fully defined file paths
*/
readDirectories: async function (directory, depth, includeStem, _stemLength) {
try {
if (!_stemLength) {
// only set this on first iteration
_stemLength = directory.length;
}
const raw = await fs.readdir(directory, { withFileTypes: true });
let children = [];
for (const dirent of raw) {
const direntPath = path.join(directory, dirent.name);
if (
(await fs.pathExists(direntPath)) &&
(await fs.lstat(direntPath)).isDirectory() &&
depth > 0
) {
const nestedChildren = await this.readDirectories(
direntPath,
depth - 1,
includeStem,
_stemLength
);
children = children.concat(nestedChildren);
}
}
if (children.length === 0) {
// if not includeStem then remove base directory and leading slahes and backslashes
return includeStem
? [directory]
: [
directory
.slice(Math.max(0, _stemLength))
.replace(/^\\+/, '')
.replace(/^\/+/, ''),
];
} else {
return children;
}
} catch (ex) {
Util.logger.error('File.readDirectories:: error | ' + ex.message);
Util.logger.debug(ex.stack);
}
},
/**
* reads directories to a specific depth returning an array
* of file paths to be iterated over using sync api (required in constructors)
* TODO - merge with readDirectories. so far the logic is really different
*
* @example ['deploy/mcdev/bu1']
* @param {string} directory directory to checkin
* @param {number} [depth] how many levels to check (1 base)
* @param {boolean} [includeStem] include the parent directory in the response
* @param {number} [_stemLength] set recursively for subfolders. do not set manually!
* @returns {string[] | void} array of fully defined file paths; void on error
*/
readDirectoriesSync: function (directory, depth, includeStem, _stemLength) {
try {
const children = [];
if (!_stemLength) {
// only set this on first iteration
_stemLength = directory.length;
}
// add current directory
if (includeStem) {
children.push(directory);
} else {
// remove base directory and leading slahes and backslashes
const currentPath = directory.slice(Math.max(0, _stemLength)).replace(path.sep, '');
children.push(currentPath || '.');
}
// read all directories
const raw = fs.readdirSync(directory, { withFileTypes: true });
// loop through children of current directory (if not then this is skipped)
for (const dirent of raw) {
// if directory found and should get children then recursively call
if (dirent.isDirectory() && depth > 0) {
const nestedChildren = this.readDirectoriesSync(
path.join(directory, dirent.name),
depth - 1,
includeStem,
_stemLength
);
children.push(...nestedChildren);
}
}
return children;
} catch (ex) {
Util.logger.error('File.readDirectoriesSync:: error | ' + ex.message);
Util.logger.debug(ex.stack);
}
},
/**
* helper that splits the config back into auth & config parts to save them separately
*
* @param {TYPE.Mcdevrc} properties central properties object
* @returns {Promise.<void>} -
*/
async saveConfigFile(properties) {
// we want to save to save the full version here to allow us to upgrade configs properly in the future
properties.version = packageJson.version;
await this.writeJSONToFile('', Util.configFileName.split('.json')[0], properties);
Util.logger.info(`✔️ ${Util.configFileName} and ${Util.authFileName} saved successfully`);
},
/**
* Initalises Prettier formatting lib async.
*
* @param {string} [filetype='html'] filetype ie. JSON or SSJS
* @returns {Promise.<boolean>} success of config load
*/
async initPrettier(filetype = 'html') {
if (FileFs.prettierConfig === null || FileFs.prettierConfigFileType !== filetype) {
// run this if no config was yet found or if the filetype previously used to initialize it differs (because it results in a potentially different config!)
FileFs.prettierConfigFileType = filetype;
try {
// pass in project dir with fake index.html to avoid "no parser" error
// by using process.cwd we are limiting ourselves to a config in the project root
// note: overrides will be ignored unless they are for *.html if hand in an html file here. This method includes the overrides corresponding to the file we pass in
FileFs.prettierConfig = await prettier.resolveConfig(
path.join(process.cwd(), 'index.' + filetype)
);
if (FileFs.prettierConfig === null) {
// set to false to avoid re-running this after an initial failure
throw new Error(
`No .prettierrc found in your project directory. Please run 'mcdev upgrade' to create it`
);
}
return true;
} catch (ex) {
FileFs.prettierConfig = false;
Util.logger.error('Cannot apply auto-formatting to your code:' + ex.message);
return false;
}
} else {
return false;
}
},
};
const FileFs = { ...fs, ...File };
FileFs.prettierConfig = null;
FileFs.prettierConfigFileType = null;
module.exports = FileFs;