442 lines
13 KiB
JavaScript
442 lines
13 KiB
JavaScript
/*
|
|
* decaffeinate suggestions:
|
|
* DS102: Remove unnecessary code created because of implicit returns
|
|
* DS207: Consider shorter variations of null checks
|
|
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
*/
|
|
/**
|
|
Implementation of the 'build' verb for HackMyResume.
|
|
@module verbs/build
|
|
@license MIT. See LICENSE.md for details.
|
|
*/
|
|
|
|
|
|
|
|
const _ = require('underscore');
|
|
const PATH = require('path');
|
|
const MKDIRP = require('mkdirp');
|
|
const extend = require('extend');
|
|
const parsePath = require('parse-filepath');
|
|
const RConverter = require('fresh-jrs-converter');
|
|
const HMSTATUS = require('../core/status-codes');
|
|
const HMEVENT = require('../core/event-codes');
|
|
const RTYPES = {
|
|
FRESH: require('../core/fresh-resume'),
|
|
JRS: require('../core/jrs-resume')
|
|
};
|
|
const _opts = require('../core/default-options');
|
|
const FRESHTheme = require('../core/fresh-theme');
|
|
const JRSTheme = require('../core/jrs-theme');
|
|
const ResumeFactory = require('../core/resume-factory');
|
|
const _fmts = require('../core/default-formats');
|
|
const Verb = require('../verbs/verb');
|
|
|
|
//const _err = null;
|
|
//const _log = null;
|
|
let _rezObj = null;
|
|
//const build = null;
|
|
//const prep = null;
|
|
//const single = null;
|
|
//const verifyOutputs = null;
|
|
//const addFreebieFormats = null;
|
|
//const expand = null;
|
|
//const verifyTheme = null;
|
|
//const loadTheme = null;
|
|
|
|
/** An invokable resume generation command. */
|
|
class BuildVerb extends Verb {
|
|
|
|
/** Create a new build verb. */
|
|
constructor() { super('build', _build); }
|
|
}
|
|
|
|
|
|
module.exports = BuildVerb;
|
|
|
|
|
|
|
|
/**
|
|
Given a source resume in FRESH or JRS format, a destination resume path, and a
|
|
theme file, generate 0..N resumes in the desired formats.
|
|
@param src Path to the source JSON resume file: "rez/resume.json".
|
|
@param dst An array of paths to the target resume file(s).
|
|
@param opts Generation options.
|
|
*/
|
|
var _build = function( src, dst, opts ) {
|
|
|
|
let err;
|
|
if (!src || !src.length) {
|
|
this.err(HMSTATUS.resumeNotFound, {quit: true});
|
|
return null;
|
|
}
|
|
|
|
_prep.call(this, src, dst, opts);
|
|
|
|
// Load input resumes as JSON...
|
|
const sheetObjects = ResumeFactory.load(src, {
|
|
format: null, objectify: false, quit: true, inner: {
|
|
sort: _opts.sort,
|
|
private: _opts.private
|
|
}
|
|
}
|
|
, this);
|
|
|
|
// Explicit check for any resume loading errors...
|
|
const problemSheets = _.filter(sheetObjects, so => so.fluenterror);
|
|
if (problemSheets && problemSheets.length) {
|
|
problemSheets[0].quit = true; // can't go on
|
|
this.err(problemSheets[0].fluenterror, problemSheets[0]);
|
|
return null;
|
|
}
|
|
|
|
// Get the collection of raw JSON sheets
|
|
const sheets = sheetObjects.map(r => r.json);
|
|
|
|
// Load the theme...
|
|
let theme = null;
|
|
this.stat(HMEVENT.beforeTheme, { theme: _opts.theme });
|
|
try {
|
|
const tFolder = _verifyTheme.call(this, _opts.theme);
|
|
if (tFolder.fluenterror) {
|
|
tFolder.quit = true;
|
|
this.err(tFolder.fluenterror, tFolder);
|
|
return;
|
|
}
|
|
theme = (_opts.themeObj = _loadTheme(tFolder));
|
|
_addFreebieFormats(theme);
|
|
} catch (error) {
|
|
err = error;
|
|
const newEx = {
|
|
fluenterror: HMSTATUS.themeLoad,
|
|
inner: err,
|
|
attempted: _opts.theme,
|
|
quit: true
|
|
};
|
|
this.err(HMSTATUS.themeLoad, newEx);
|
|
return null;
|
|
}
|
|
|
|
this.stat(HMEVENT.afterTheme, {theme});
|
|
|
|
// Check for invalid outputs...
|
|
const inv = _verifyOutputs.call(this, dst, theme);
|
|
if (inv && inv.length) {
|
|
this.err(HMSTATUS.invalidFormat, {data: inv, theme, quit: true});
|
|
return null;
|
|
}
|
|
|
|
//# Merge input resumes, yielding a single source resume...
|
|
let rez = null;
|
|
if (sheets.length > 1) {
|
|
const isFRESH = !sheets[0].basics;
|
|
const mixed = _.any(sheets, function(s) { if (isFRESH) { return s.basics; } else { return !s.basics; } });
|
|
this.stat(HMEVENT.beforeMerge, { f: _.clone(sheetObjects), mixed });
|
|
if (mixed) {
|
|
this.err(HMSTATUS.mixedMerge);
|
|
}
|
|
rez = _.reduceRight(sheets, ( a, b ) => extend( true, b, a ));
|
|
this.stat(HMEVENT.afterMerge, { r: rez });
|
|
} else {
|
|
rez = sheets[0];
|
|
}
|
|
|
|
// Convert the merged source resume to the theme's format, if necessary..
|
|
const orgFormat = rez.basics ? 'JRS' : 'FRESH';
|
|
const toFormat = theme.render ? 'JRS' : 'FRESH';
|
|
if (toFormat !== orgFormat) {
|
|
this.stat(HMEVENT.beforeInlineConvert);
|
|
rez = RConverter[ `to${toFormat}` ]( rez );
|
|
this.stat(HMEVENT.afterInlineConvert, { file: sheetObjects[0].file, fmt: toFormat });
|
|
}
|
|
|
|
// Announce the theme
|
|
this.stat(HMEVENT.applyTheme, {r: rez, theme});
|
|
|
|
// Load the resume into a FRESHResume or JRSResume object
|
|
_rezObj = new (RTYPES[ toFormat ])().parseJSON( rez, {private: _opts.private} );
|
|
|
|
// Expand output resumes...
|
|
const targets = _expand(dst, theme);
|
|
|
|
// Run the transformation!
|
|
_.each(targets, function(t) {
|
|
if (this.hasError() && opts.assert) { return { }; }
|
|
t.final = _single.call(this, t, theme, targets);
|
|
if (t.final != null ? t.final.fluenterror : undefined) {
|
|
t.final.quit = opts.assert;
|
|
this.err(t.final.fluenterror, t.final);
|
|
}
|
|
}
|
|
, this);
|
|
|
|
const results = {
|
|
sheet: _rezObj,
|
|
targets,
|
|
processed: targets
|
|
};
|
|
|
|
if (this.hasError() && !opts.assert) {
|
|
this.reject(results);
|
|
} else if (!this.hasError()) {
|
|
this.resolve(results);
|
|
}
|
|
|
|
return results;
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
Prepare for a BUILD run.
|
|
*/
|
|
var _prep = function( src, dst, opts ) {
|
|
// Cherry-pick options //_opts = extend( true, _opts, opts );
|
|
_opts.theme = (opts.theme && opts.theme.toLowerCase().trim()) || 'modern';
|
|
_opts.prettify = opts.prettify === true;
|
|
_opts.private = opts.private === true;
|
|
_opts.noescape = opts.noescape === true;
|
|
_opts.css = opts.css;
|
|
_opts.pdf = opts.pdf;
|
|
_opts.wrap = opts.wrap || 60;
|
|
_opts.stitles = opts.sectionTitles;
|
|
_opts.tips = opts.tips;
|
|
_opts.errHandler = opts.errHandler;
|
|
_opts.noTips = opts.noTips;
|
|
_opts.debug = opts.debug;
|
|
_opts.sort = opts.sort;
|
|
_opts.wkhtmltopdf = opts.wkhtmltopdf;
|
|
const that = this;
|
|
|
|
// Set up callbacks for internal generators
|
|
_opts.onTransform = function(info) {
|
|
that.stat(HMEVENT.afterTransform, info);
|
|
};
|
|
_opts.beforeWrite = function(info) {
|
|
that.stat(HMEVENT.beforeWrite, info);
|
|
};
|
|
_opts.afterWrite = function(info) {
|
|
that.stat(HMEVENT.afterWrite, info);
|
|
};
|
|
|
|
|
|
// If two or more files are passed to the GENERATE command and the TO
|
|
// keyword is omitted, the last file specifies the output file.
|
|
( (src.length > 1) && ( !dst || !dst.length ) ) && dst.push( src.pop() );
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
Generate a single target resume such as "out/rez.html" or "out/rez.doc".
|
|
TODO: Refactor.
|
|
@param targInfo Information for the target resume.
|
|
@param theme A FRESHTheme or JRSTheme object.
|
|
*/
|
|
var _single = function( targInfo, theme, finished ) {
|
|
|
|
let ret = null;
|
|
let ex = null;
|
|
const f = targInfo.file;
|
|
|
|
try {
|
|
|
|
if (!targInfo.fmt) {
|
|
return { };
|
|
}
|
|
let theFormat = null;
|
|
|
|
this.stat(HMEVENT.beforeGenerate, {
|
|
fmt: targInfo.fmt.outFormat,
|
|
file: PATH.relative(process.cwd(), f)
|
|
}
|
|
);
|
|
|
|
_opts.targets = finished;
|
|
|
|
// If targInfo.fmt.files exists, this format is backed by a document.
|
|
// Fluent/FRESH themes are handled here.
|
|
if (targInfo.fmt.files && targInfo.fmt.files.length) {
|
|
theFormat = _fmts.filter( fmt => fmt.name === targInfo.fmt.outFormat)[0];
|
|
MKDIRP.sync(PATH.dirname( f ));
|
|
ret = theFormat.gen.generate(_rezObj, f, _opts);
|
|
|
|
// Otherwise this is an ad-hoc format (JSON, YML, or PNG) that every theme
|
|
// gets "for free".
|
|
} else {
|
|
theFormat = _fmts.filter( fmt => fmt.name === targInfo.fmt.outFormat)[0];
|
|
const outFolder = PATH.dirname(f);
|
|
MKDIRP.sync(outFolder); // Ensure dest folder exists;
|
|
ret = theFormat.gen.generate(_rezObj, f, _opts);
|
|
}
|
|
|
|
} catch (e) {
|
|
ex = e;
|
|
}
|
|
|
|
this.stat(HMEVENT.afterGenerate, {
|
|
fmt: targInfo.fmt.outFormat,
|
|
file: PATH.relative(process.cwd(), f),
|
|
error: ex
|
|
}
|
|
);
|
|
|
|
if (ex) {
|
|
if (ex.fluenterror) {
|
|
ret = ex;
|
|
} else {
|
|
ret = {fluenterror: HMSTATUS.generateError, inner: ex};
|
|
}
|
|
}
|
|
return ret;
|
|
};
|
|
|
|
|
|
|
|
/** Ensure that user-specified outputs/targets are valid. */
|
|
var _verifyOutputs = function( targets, theme ) {
|
|
this.stat(HMEVENT.verifyOutputs, {targets, theme});
|
|
return _.reject(targets.map( function( t ) {
|
|
const pathInfo = parsePath(t);
|
|
return {format: pathInfo.extname.substr(1)}; }),
|
|
t => (t.format === 'all') || theme.hasFormat( t.format ));
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
Reinforce the chosen theme with "freebie" formats provided by HackMyResume.
|
|
A "freebie" format is an output format such as JSON, YML, or PNG that can be
|
|
generated directly from the resume model or from one of the theme's declared
|
|
output formats. For example, the PNG format can be generated for any theme
|
|
that declares an HTML format; the theme doesn't have to provide an explicit
|
|
PNG template.
|
|
@param theTheme A FRESHTheme or JRSTheme object.
|
|
*/
|
|
var _addFreebieFormats = function( theTheme ) {
|
|
// Add freebie formats (JSON, YAML, PNG) every theme gets...
|
|
// Add HTML-driven PNG only if the theme has an HTML format.
|
|
theTheme.formats.json = theTheme.formats.json || {
|
|
freebie: true, title: 'json', outFormat: 'json', pre: 'json',
|
|
ext: 'json', path: null, data: null
|
|
};
|
|
theTheme.formats.yml = theTheme.formats.yml || {
|
|
freebie: true, title: 'yaml', outFormat: 'yml', pre: 'yml',
|
|
ext: 'yml', path: null, data: null
|
|
};
|
|
if (theTheme.formats.html && !theTheme.formats.png) {
|
|
theTheme.formats.png = {
|
|
freebie: true, title: 'png', outFormat: 'png',
|
|
ext: 'yml', path: null, data: null
|
|
};
|
|
}
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
Expand output files. For example, "foo.all" should be expanded to
|
|
["foo.html", "foo.doc", "foo.pdf", "etc"].
|
|
@param dst An array of output files as specified by the user.
|
|
@param theTheme A FRESHTheme or JRSTheme object.
|
|
*/
|
|
var _expand = function( dst, theTheme ) {
|
|
|
|
// Set up the destination collection. It's either the array of files passed
|
|
// by the user or 'out/resume.all' if no targets were specified.
|
|
const destColl = (dst && dst.length && dst) || [PATH.normalize('out/resume.all')];
|
|
|
|
// Assemble an array of expanded target files... (can't use map() here)
|
|
const targets = [];
|
|
destColl.forEach(function(t) {
|
|
const to = PATH.resolve(t);
|
|
const pa = parsePath(to);
|
|
const fmat = pa.extname || '.all';
|
|
return targets.push.apply( targets,
|
|
fmat === '.all'
|
|
? Object.keys( theTheme.formats ).map( function( k ) {
|
|
const z = theTheme.formats[k];
|
|
return { file: to.replace( /all$/g, z.outFormat ), fmt: z };
|
|
})
|
|
: [{ file: to, fmt: theTheme.getFormat( fmat.slice(1) ) }]
|
|
);
|
|
});
|
|
return targets;
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
Verify the specified theme name/path.
|
|
*/
|
|
var _verifyTheme = function( themeNameOrPath ) {
|
|
|
|
// First, see if this is one of the predefined FRESH themes. There are only a
|
|
// handful of these, but they may change over time, so we need to query
|
|
// the official source of truth: the fresh-themes repository, which mounts the
|
|
// themes conveniently by name to the module object, and which is embedded
|
|
// locally inside the HackMyResume installation.
|
|
let tFolder;
|
|
const themesObj = require('fresh-themes');
|
|
if (_.has(themesObj.themes, themeNameOrPath)) {
|
|
tFolder = PATH.join(
|
|
parsePath( require.resolve('fresh-themes') ).dirname,
|
|
'/themes/',
|
|
themeNameOrPath
|
|
);
|
|
} else {
|
|
// Otherwsie it's a path to an arbitrary FRESH or JRS theme sitting somewhere
|
|
// on the user's system (or, in the future, at a URI).
|
|
tFolder = PATH.resolve(themeNameOrPath);
|
|
}
|
|
|
|
// In either case, make sure the theme folder exists
|
|
const exists = require('path-exists').sync;
|
|
if (exists(tFolder)) {
|
|
return tFolder;
|
|
} else {
|
|
return {fluenterror: HMSTATUS.themeNotFound, data: _opts.theme};
|
|
}
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
Load the specified theme, which could be either a FRESH theme or a JSON Resume
|
|
theme (or both).
|
|
*/
|
|
var _loadTheme = function( tFolder ) {
|
|
|
|
const themeJsonPath = PATH.join(tFolder, 'theme.json'); // [^1]
|
|
const exists = require('path-exists').sync;
|
|
|
|
// Create a FRESH or JRS theme object
|
|
const theTheme =
|
|
exists(themeJsonPath)
|
|
? new FRESHTheme().open(tFolder)
|
|
: new JRSTheme().open(tFolder);
|
|
|
|
// Cache the theme object
|
|
_opts.themeObj = theTheme;
|
|
return theTheme;
|
|
};
|
|
|
|
|
|
// FOOTNOTES
|
|
// ------------------------------------------------------------------------------
|
|
// [^1] We don't know ahead of time whether this is a FRESH or JRS theme.
|
|
// However, all FRESH themes have a theme.json file, so we'll use that as a
|
|
// canary for now, as an interim solution.
|
|
//
|
|
// Unfortunately, with the exception of FRESH's theme.json, both FRESH and
|
|
// JRS themes are free-form and don't have a ton of reliable distinguishing
|
|
// marks, which makes a simple task like ad hoc theme detection harder than
|
|
// it should be to do cleanly.
|
|
//
|
|
// Another complicating factor is that it's possible for a theme to be BOTH.
|
|
// That is, a single set of theme files can serve as a FRESH theme -and- a
|
|
// JRS theme.
|
|
//
|
|
// TODO: The most robust way to deal with all these issues is with a strong
|
|
// theme validator. If a theme structure validates as a particular theme
|
|
// type, then for all intents and purposes, it IS a theme of that type.
|