/**
* @module
* @author Özüm Eldoğan
*/
var path = require('path');
var fs = require('fs');
var _ = require('lodash');
var UglifyJS = require("uglify-js");
var md5 = require('MD5');
/**
* Writes given content as an auto generated part to file considering options.
* @param {string} filePath - File which content is written into
* @param {string} name - Name of the part. This is used for differentiating it from other auto generated content.
* @param {string} content - Content to write
* @param {genOptions} options - Options how part is generated.
* @example
* var autogen = require('auto-generate');
* var content = 'var someCode = "Joe";' // etc. auto generated code
* autogen(path.join(__dirname, 'model', 'account.js'), 'model', content);
*/
module.exports = function (filePath, name, content, options) {
options = _.defaults(options || {}, {
overWrite : true, // If file already exists. Update the file.
overWriteEvenChanged : false, // Overwrite generated part even it is modified manually.
backup : true, // Create backup if a file is overwritten.
ignoreWhitespaceChange : true, // Assume it is unmodified if whitespace is modified.
ignoreCommentChange : false, // Assume it is unmodified if comment is changed. This also enables ignoreWhitespaceChange too.
atStart : true // Add text at the end of file. Otherwise it adds at the end. Either way replaces original text in place.
});
if (!filePath || !name ) { throw new Error('filePath and name are required.'); }
var oldFileContent = '';
var newFileContent = '';
var newPart = createPart(name, content, options);
var oldPart;
var error;
if (fileExists(filePath)) {
oldFileContent = fs.readFileSync(filePath).toString();
oldPart = getPart(name, oldFileContent, options);
if (oldPart) {
if (!oldPart.isChanged || (oldPart.isChanged && options.overWriteEvenChanged)) {
newFileContent = oldFileContent.replace(getRegularExpression(name), newPart );
} else {
error = new Error('File is changed by hand. Use overWriteEvenChanged option to update. File is not updated: ' + filePath );
}
} else {
newFileContent = (options.atStart) ? newPart + oldFileContent : oldFileContent + newPart;
}
if (options.backup && !error) {
makeBackup(filePath);
}
} else { // New file
newFileContent = newPart;
}
if (error) { throw error; }
fs.writeFileSync(filePath, newFileContent);
};
var signature = '//!-AG';
var template = {
warning : _.template('// Do NOT edit text between auto start and auto end. It is auto generated.\n\n'),
md5 : _.template('\n\n// <%= md5 %> You can edit safely after auto end.\n')
};
/**
* Options used to create auto generated part.
* @typedef {Object} genOptions
* @property {boolean} overWrite - If file already exists. Update the file.
* @property {boolean} overWriteEvenChanged - Overwrite generated part even it is modified manually.
* @property {boolean} backup - Create backup if a file is overwritten.
* @property {boolean} ignoreWhitespaceChange - Assume it is unmodified if whitespace is modified when calculating digest.
* @property {boolean} ignoreCommentChange - Assume it is unmodified if comment is changed when calculating digest. This also enables ignoreWhitespaceChange too.
* @property {boolean} atStart - Add text at the end of file. Otherwise it adds at the end. Either way replaces original text in place.
*/
/**
* Object which contains detailed info of an auto generated part.
* @private
* @typedef {Object} partInfo
* @property {string} startLine - Start line (opening tag / marker) of auto generated part.
* @property {string} warningLine - Warning message line of auto generated part.
* @property {string} content - Auto generated content.
* @property {string} md5Line - Line which contains md5 of the content.
* @property {string} oldDigest - MD5 which is written in the file.
* @property {string} newDigest - MD5 calculated freshly for the content.
* @property {boolean} isChanged - Indicates if part is modified by comparing MD5 written in file with new calculated MD5
* @property {string} endLine - End line (closing tag / marker) of auto generated part.
*/
/**
* Generates and returns start line (opening tag / marker) of auto generated part.
* @private
* @param {string} name - name of the auto generated part
* @returns {string} - first line of the auto generated part.
*/
function autoStartLine(name) {
var pre = new Array(Math.floor((60 - name.length) / 2) - 3).join('-');
var post = new Array(60 - pre.length - name.length).join('-');
return signature + 'S' + pre + ' Auto Start: ' + name + ' ' + post + '\n';
}
/**
* Generates and returns end line (closing tag / marker) of auto generated part.
* @private
* @param {string} name - name of the auto generated part.
* @returns {string} - last line of the auto generated part.
*/
function autoEndLine(name) {
var pre = new Array(Math.floor((62 - name.length) / 2) - 3).join('-');
var post = new Array(62 - pre.length - name.length).join('-');
return signature + 'E' + pre + ' Auto End: ' + name + ' ' + post + '\n\n';
}
/**
* Creates backup of a file. To do this, it creates a directory called BACKUP in the same directory where
* original file is located in. Backup file name has a suffix of ISO style date and time.
* ie. 'model.js' becomes '2014-01-12 22.02.23.345 model.js'
* @private
* @param {string} filePath - Absolute path of file
*/
function makeBackup(filePath) {
var dateString = new Date().toISOString().replace(/:/g, '.').replace('Z', '').replace('T', ' ');
try { fs.mkdirSync(path.join(path.dirname(filePath), 'BACKUP')); }
catch(err) { if (err.code != 'EEXIST') { throw err } }
fs.writeFileSync(path.join(path.dirname(filePath), 'BACKUP', dateString + ' ' + path.basename(filePath) ), fs.readFileSync(path.normalize(filePath)));
}
/**
* Generates and returns auto generated content wrapped by start line, end line, digest (such as md5)
* and other details.
* @private
* @param {string} name - Name of the auto generated part
* @param {string} content - Auto generated content
* @param {genOptions} options - Options how part is generated.
* @returns {string} - Generated part
*/
function createPart(name, content, options) {
if (!name || !options) { throw new Error('name and options are required.'); }
var digest = calculateMD5(content, options);
newContent = autoStartLine(name);
newContent += template.warning();
newContent += content || '';
newContent += template.md5({md5: digest});
newContent += autoEndLine(name);
return newContent;
}
/**
* Returns regular expression object to find and/or replace auto generated part.
* @private
* @param {string} name - name of the auto generated part
* @returns {RegExp} - Regular expression object
*/
function getRegularExpression(name) {
// $1: Start Line, $2: Warning Line, $3: Content, $4: MD5 Line, $5: MD5, $6: End Line
var reAuto = _.template('(<%= startLine %>)(<%= warningLine %>)((?:.|\n|\r)*?)(<%= md5Line %>)(<%= endLine %>?)');
var reString = reAuto({ startLine: autoStartLine(name), warningLine: template.warning(), md5Line: template.md5({ md5: '([a-fA-F0-9]{32})' }), endLine: autoEndLine(name) });
return new RegExp(reString, 'm');
}
/**
* Finds auto generated part and returns an object which contains information about auto generated part.
* If auto part with requested name cannot be found, it returns null.
* @private
* @param name - Name of the auto generated part.
* @param fileContent - content of the file which part is searched in.
* @param {genOptions} options - Options how part is generated.
* @returns {partInfo|null} - Object which contains info about auto generated part.
*/
function getPart(name, fileContent, options) {
if (!name || !options) { throw new Error('name and options are required.'); }
var parts = fileContent.match(getRegularExpression(name));
if ( parts ) { // Aranan bölüm varsa
var fileContentDigest = calculateMD5(parts[3], options);
return {
all : parts[0],
startLine : parts[1],
warningLine : parts[2],
content : parts[3],
md5Line : parts[4],
oldDigest : parts[5],
newDigest : fileContentDigest,
isChanged : fileContentDigest != parts[5],
endLine : parts[6]
}
}
else {
return null;
}
}
/**
* Checks if the file exists at the given path. Returns true if it exists, false otherwise.
* @private
* @param {string} file - Path of the file to check
* @returns {boolean}
*/
function fileExists(file) {
try {
var targetStat = fs.statSync(file);
if (targetStat.isDirectory() ) {
throw new Error("File exists but it's a driectory: " + file);
}
}
catch(err) {
if (err.code == 'ENOENT') { // No such file or directory
return false;
}
else {
throw err;
}
}
return true;
}
/**
* Claculates MD5 of the given text according to options. If ignoreWhitespaceChange and/or ignoreCommentChange options
* are true, MD5 is calculated after UglifyJS minified the source code according to given options.
* @private
* @param {string} text - Text to calculate MD5 from
* @param {genOptions} options - Options how MD5 is calculated
* @returns {string} - MD5 of the text
*/
function calculateMD5(text, options) {
// Calculates options based on options. It is possible to ignore whitespaces and comments. (Look write function)
// If comments are ignored, whitespaces are ignored automatically.
if (!options) { throw new Error('Options parameter is required'); }
if (options.ignoreWhitespaceChange || options.ignoreCommentChange) {
var outputOptions = options.ignoreCommentChange ? null : { comments:function() {return true}};
try { text = UglifyJS.minify(text, {fromString: true, output: outputOptions }).code; }
catch(err) { throw new Error( 'Cannot minify JS file: ' + err.message ) }
}
return md5(text);
}