HackMyResume/src/verbs/build.coffee

402 lines
12 KiB
CoffeeScript

###*
Implementation of the 'build' verb for HackMyResume.
@module verbs/build
@license MIT. See LICENSE.md for details.
###
_ = require 'underscore'
PATH = require 'path'
FS = require 'fs'
MD = require 'marked'
MKDIRP = require 'mkdirp'
extend = require 'extend'
parsePath = require 'parse-filepath'
RConverter = require 'fresh-jrs-converter'
HMSTATUS = require '../core/status-codes'
HMEVENT = require '../core/event-codes'
RTYPES =
FRESH: require '../core/fresh-resume'
JRS: require '../core/jrs-resume'
_opts = require '../core/default-options'
FRESHTheme = require '../core/fresh-theme'
JRSTheme = require '../core/jrs-theme'
ResumeFactory = require '../core/resume-factory'
_fmts = require '../core/default-formats'
Verb = require '../verbs/verb'
_err = null
_log = null
_rezObj = null
build = null
prep = null
single = null
verifyOutputs = null
addFreebieFormats = null
expand = null
verifyTheme = null
loadTheme = null
###* An invokable resume generation command. ###
module.exports = class BuildVerb extends Verb
###* Create a new build verb. ###
constructor: () -> super 'build', _build
###*
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.
###
_build = ( src, dst, opts ) ->
if !src || !src.length
@err HMSTATUS.resumeNotFound, quit: true
return null
_prep.call @, src, dst, opts
# Load input resumes as JSON...
sheetObjects = ResumeFactory.load src,
format: null, objectify: false, quit: true, inner: {
sort: _opts.sort
private: _opts.private
}
, @
# Explicit check for any resume loading errors...
problemSheets = _.filter sheetObjects, (so) -> so.fluenterror
if problemSheets and problemSheets.length
problemSheets[0].quit = true # can't go on
@err problemSheets[0].fluenterror, problemSheets[0]
return null
# Get the collection of raw JSON sheets
sheets = sheetObjects.map (r) -> r.json
# Load the theme...
theme = null
@stat HMEVENT.beforeTheme, { theme: _opts.theme }
try
tFolder = _verifyTheme.call @, _opts.theme
if tFolder.fluenterror
tFolder.quit = true
@err tFolder.fluenterror, tFolder
return
theme = _opts.themeObj = _loadTheme tFolder
_addFreebieFormats theme
catch err
newEx =
fluenterror: HMSTATUS.themeLoad
inner: err
attempted: _opts.theme
quit: true
@err HMSTATUS.themeLoad, newEx
return null
@stat HMEVENT.afterTheme, theme: theme
# Check for invalid outputs...
inv = _verifyOutputs.call @, dst, theme
if inv && inv.length
@err HMSTATUS.invalidFormat, data: inv, theme: theme, quit: true
return null
## Merge input resumes, yielding a single source resume...
rez = null
if sheets.length > 1
isFRESH = !sheets[0].basics
mixed = _.any sheets, (s) -> return if isFRESH then s.basics else !s.basics
@stat HMEVENT.beforeMerge, { f: _.clone(sheetObjects), mixed: mixed }
if mixed
@err HMSTATUS.mixedMerge
rez = _.reduceRight sheets, ( a, b, idx ) ->
extend( true, b, a )
@stat HMEVENT.afterMerge, { r: rez }
else
rez = sheets[0];
# Convert the merged source resume to the theme's format, if necessary..
orgFormat = if rez.basics then 'JRS' else 'FRESH';
toFormat = if theme.render then 'JRS' else 'FRESH';
if toFormat != orgFormat
@stat HMEVENT.beforeInlineConvert
rez = RConverter[ 'to' + toFormat ]( rez );
@stat HMEVENT.afterInlineConvert, { file: sheetObjects[0].file, fmt: toFormat }
# Announce the theme
@stat HMEVENT.applyTheme, r: rez, theme: theme
# Load the resume into a FRESHResume or JRSResume object
_rezObj = new (RTYPES[ toFormat ])().parseJSON( rez, private: _opts.private );
# Expand output resumes...
targets = _expand dst, theme
# Run the transformation!
_.each targets, (t) ->
return { } if @hasError() and opts.assert
t.final = _single.call @, t, theme, targets
if t.final?.fluenterror
t.final.quit = opts.assert
@err t.final.fluenterror, t.final
return
, @
results =
sheet: _rezObj
targets: targets
processed: targets
if @hasError() and !opts.assert
@reject results
else if !@hasError()
@resolve results
results
###*
Prepare for a BUILD run.
###
_prep = ( src, dst, opts ) ->
# Cherry-pick options //_opts = extend( true, _opts, opts );
_opts.theme = (opts.theme && opts.theme.toLowerCase().trim()) || 'modern';
_opts.prettify = opts.prettify is true
_opts.private = opts.private is true
_opts.noescape = opts.noescape is 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
that = @
# Set up callbacks for internal generators
_opts.onTransform = (info) ->
that.stat HMEVENT.afterTransform, info; return
_opts.beforeWrite = (info) ->
that.stat HMEVENT.beforeWrite, info; return
_opts.afterWrite = (info) ->
that.stat HMEVENT.afterWrite, info; return
# 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() )
return
###*
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.
###
_single = ( targInfo, theme, finished ) ->
ret = null
ex = null
f = targInfo.file
try
if !targInfo.fmt
return { }
fType = targInfo.fmt.outFormat
fName = PATH.basename f, '.' + fType
theFormat = null
@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) ->
return 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) ->
return fmt.name == targInfo.fmt.outFormat
)[0];
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
ret
###* Ensure that user-specified outputs/targets are valid. ###
_verifyOutputs = ( targets, theme ) ->
@stat HMEVENT.verifyOutputs, targets: targets, theme: theme
_.reject targets.map( ( t ) ->
pathInfo = parsePath t
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.
###
_addFreebieFormats = ( 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
}
return
###*
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.
###
_expand = ( 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.
destColl = (dst && dst.length && dst) || [PATH.normalize('out/resume.all')];
# Assemble an array of expanded target files... (can't use map() here)
targets = [];
destColl.forEach (t) ->
to = PATH.resolve(t)
pa = parsePath(to)
fmat = pa.extname || '.all';
targets.push.apply( targets,
if fmat == '.all'
then Object.keys( theTheme.formats ).map( ( k ) ->
z = theTheme.formats[k]
return { file: to.replace( /all$/g, z.outFormat ), fmt: z }
)
else [{ file: to, fmt: theTheme.getFormat( fmat.slice(1) ) }]
)
targets
###*
Verify the specified theme name/path.
###
_verifyTheme = ( 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.
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
exists = require('path-exists').sync
if exists tFolder
tFolder
else
fluenterror: HMSTATUS.themeNotFound, data: _opts.theme
###*
Load the specified theme, which could be either a FRESH theme or a JSON Resume
theme (or both).
###
_loadTheme = ( tFolder ) ->
themeJsonPath = PATH.join tFolder, 'theme.json' # [^1]
exists = require('path-exists').sync
# Create a FRESH or JRS theme object
theTheme =
if exists themeJsonPath
then new FRESHTheme().open tFolder
else new JRSTheme().open tFolder
# Cache the theme object
_opts.themeObj = theTheme;
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.