1
0
mirror of https://github.com/JuanCanham/HackMyResume.git synced 2025-05-02 12:27:08 +01:00

Finish HackMyCore reshaping.

Reintroduce HackMyCore, dropping the interim submodule, and reorganize
and improve tests.
This commit is contained in:
hacksalot
2016-01-29 15:23:57 -05:00
parent e9971eb882
commit 0f65e4c9f3
130 changed files with 5384 additions and 337 deletions

View File

@ -6,19 +6,19 @@ Error-handling routines for HackMyResume.
HMSTATUS = require('../hmc/dist/core/status-codes')
PKG = require('../../package.json')
FS = require('fs')
FCMD = require('../hmc')
PATH = require('path')
WRAP = require('word-wrap')
M2C = require('../hmc/dist/utils/md2chalk.js')
chalk = require('chalk')
extend = require('extend')
YAML = require('yamljs')
printf = require('printf')
SyntaxErrorEx = require('../hmc/dist/utils/syntax-error-ex')
require('string.prototype.startswith')
HMSTATUS = require '../core/status-codes'
PKG = require '../../package.json'
FS = require 'fs'
FCMD = require '../index'
PATH = require 'path'
WRAP = require 'word-wrap'
M2C = require '../utils/md2chalk'
chalk = require 'chalk'
extend = require 'extend'
YAML = require 'yamljs'
printf = require 'printf'
SyntaxErrorEx = require '../utils/syntax-error-ex'
require 'string.prototype.startswith'

View File

@ -12,11 +12,11 @@ Command-line interface (CLI) for HackMyResume.
try {
require('./cli/main')( process.argv );
require('./main')( process.argv );
}
catch( ex ) {
require('./cli/error').err( ex, true );
require('./error').err( ex, true );
}

View File

@ -6,16 +6,16 @@ Definition of the `main` function.
HMR = require '../hmc'
HMR = require '../index'
PKG = require '../../package.json'
FS = require 'fs'
EXTEND = require 'extend'
chalk = require 'chalk'
PATH = require 'path'
HMSTATUS = require '../hmc/dist/core/status-codes'
HME = require '../hmc/dist/core/event-codes'
safeLoadJSON = require '../hmc/dist/utils/safe-json-loader'
StringUtils = require '../hmc/dist/utils/string.js'
HMSTATUS = require '../core/status-codes'
HME = require '../core/event-codes'
safeLoadJSON = require '../utils/safe-json-loader'
StringUtils = require '../utils/string.js'
_ = require 'underscore'
OUTPUT = require './out'
PAD = require 'string-padding'
@ -243,10 +243,12 @@ execute = ( src, dst, opts, log ) ->
v.on( 'hmr:error', -> hand.err.apply( hand, arguments ) )
v.invoke.call( v, src, dst, _opts, log )
if v.errorCode
console.log 'Exiting with error code ' + v.errorCode
process.exit(v.errorCode)
###
Initialize HackMyResume options.
TODO: Options loading is a little hacky, for two reasons:

View File

@ -7,10 +7,10 @@ Output routines for HackMyResume.
chalk = require('chalk')
HME = require('../hmc/dist/core/event-codes')
HME = require('../core/event-codes')
_ = require('underscore')
Class = require('../hmc/dist/utils/class.js')
M2C = require('../hmc/dist/utils/md2chalk.js')
Class = require('../utils/class.js')
M2C = require('../utils/md2chalk.js')
PATH = require('path')
LO = require('lodash')
FS = require('fs')
@ -109,7 +109,7 @@ OutputHandler = module.exports = Class.extend
when HME.afterAnalyze
info = evt.info
rawTpl = FS.readFileSync( PATH.join( __dirname, 'analyze.hbs' ), 'utf8')
HANDLEBARS.registerHelper( require('../hmc/dist/helpers/console-helpers') )
HANDLEBARS.registerHelper( require('../helpers/console-helpers') )
template = HANDLEBARS.compile(rawTpl, { strict: false, assumeObjects: false })
tot = 0
info.keywords.forEach (g) -> tot += g.count

View File

@ -0,0 +1,18 @@
###
Event code definitions.
@module core/default-formats
@license MIT. See LICENSE.md for details.
###
###* Supported resume formats. ###
module.exports = [
{ name: 'html', ext: 'html', gen: new (require('../generators/html-generator'))() },
{ name: 'txt', ext: 'txt', gen: new (require('../generators/text-generator'))() },
{ name: 'doc', ext: 'doc', fmt: 'xml', gen: new (require('../generators/word-generator'))() },
{ name: 'pdf', ext: 'pdf', fmt: 'html', is: false, gen: new (require('../generators/html-pdf-cli-generator'))() },
{ name: 'png', ext: 'png', fmt: 'html', is: false, gen: new (require('../generators/html-png-generator'))() },
{ name: 'md', ext: 'md', fmt: 'txt', gen: new (require('../generators/markdown-generator'))() },
{ name: 'json', ext: 'json', gen: new (require('../generators/json-generator'))() },
{ name: 'yml', ext: 'yml', fmt: 'yml', gen: new (require('../generators/json-yaml-generator'))() },
{ name: 'latex', ext: 'tex', fmt: 'latex', gen: new (require('../generators/latex-generator'))() }
]

View File

@ -0,0 +1,13 @@
###
Event code definitions.
@module core/default-options
@license MIT. See LICENSE.md for details.
###
module.exports =
theme: 'modern'
prettify: # ← See https://github.com/beautify-web/js-beautify#options
indent_size: 2
unformatted: ['em','strong']
max_char: 80, # ← See lib/html.js in above-linked repo
# wrap_line_length: 120, ← Don't use this

77
src/core/empty-jrs.json Normal file
View File

@ -0,0 +1,77 @@
{
"basics": {
"name": "",
"label": "",
"picture": "",
"email": "",
"phone": "",
"degree": "",
"website": "",
"summary": "",
"location": {
"address": "",
"postalCode": "",
"city": "",
"countryCode": "",
"region": ""
},
"profiles": [{
"network": "",
"username": "",
"url": ""
}]
},
"work": [{
"company": "",
"position": "",
"website": "",
"startDate": "",
"endDate": "",
"summary": "",
"highlights": [
""
]
}],
"awards": [{
"title": "",
"date": "",
"awarder": "",
"summary": ""
}],
"education": [{
"institution": "",
"area": "",
"studyType": "",
"startDate": "",
"endDate": "",
"gpa": "",
"courses": [ "" ]
}],
"publications": [{
"name": "",
"publisher": "",
"releaseDate": "",
"website": "",
"summary": ""
}],
"volunteer": [{
"organization": "",
"position": "",
"website": "",
"startDate": "",
"endDate": "",
"summary": "",
"highlights": [ "" ]
}],
"skills": [{
"name": "",
"level": "",
"keywords": [""]
}]
}

View File

@ -0,0 +1,35 @@
###
Event code definitions.
@module core/event-codes
@license MIT. See LICENSE.md for details.
###
module.exports =
error: -1
success: 0
begin: 1
end: 2
beforeRead: 3
afterRead: 4
beforeCreate: 5
afterCreate: 6
beforeTheme: 7
afterTheme: 8
beforeMerge: 9
afterMerge: 10
beforeGenerate: 11
afterGenerate: 12
beforeAnalyze: 13
afterAnalyze: 14
beforeConvert: 15
afterConvert: 16
verifyOutputs: 17
beforeParse: 18
afterParse: 19
beforePeek: 20
afterPeek: 21
beforeInlineConvert: 22
afterInlineConvert: 23
beforeValidate: 24
afterValidate: 25

View File

@ -0,0 +1,83 @@
###*
The HackMyResume date representation.
@license MIT. See LICENSE.md for details.
@module core/fluent-date
###
moment = require 'moment'
###*
Create a FluentDate from a string or Moment date object. There are a few date
formats to be aware of here.
1. The words "Present" and "Now", referring to the current date
2. The default "YYYY-MM-DD" format used in JSON Resume ("2015-02-10")
3. Year-and-month only ("2015-04")
4. Year-only "YYYY" ("2015")
5. The friendly HackMyResume "mmm YYYY" format ("Mar 2015" or "Dec 2008")
6. Empty dates ("", " ")
7. Any other date format that Moment.js can parse from
Note: Moment can transparently parse all or most of these, without requiring us
to specify a date format...but for maximum parsing safety and to avoid Moment
deprecation warnings, it's recommended to either a) explicitly specify the date
format or b) use an ISO format. For clarity, we handle these cases explicitly.
@class FluentDate
###
class FluentDate
constructor: (dt) ->
@rep = this.fmt dt
months = {}
abbr = {}
moment.months().forEach((m,idx) -> months[m.toLowerCase()] = idx+1 )
moment.monthsShort().forEach((m,idx) -> abbr[m.toLowerCase()]=idx+1 )
abbr.sept = 9
module.exports = FluentDate
FluentDate.fmt = ( dt, throws ) ->
throws = (throws == undefined || throws == null) || throws
if typeof dt == 'string' or dt instanceof String
dt = dt.toLowerCase().trim()
if /^(present|now|current)$/.test(dt) # "Present", "Now"
return moment()
else if /^\D+\s+\d{4}$/.test(dt) # "Mar 2015"
parts = dt.split(' ');
month = (months[parts[0]] || abbr[parts[0]]);
temp = parts[1] + '-' + (month < 10 ? '0' + month : month.toString());
return moment temp, 'YYYY-MM'
else if /^\d{4}-\d{1,2}$/.test(dt) # "2015-03", "1998-4"
return moment dt, 'YYYY-MM'
else if /^\s*\d{4}\s*$/.test(dt) # "2015"
return moment dt, 'YYYY'
else if /^\s*$/.test(dt) # "", " "
defTime =
isNull: true
isBefore: ( other ) ->
if other and !other.isNull then true else false
isAfter: ( other ) ->
if other and !other.isNull then false else false
unix: () -> 0
format: () -> ''
diff: () -> 0
return defTime
else
mt = moment dt
if mt.isValid()
return mt
if throws
throw 'Invalid date format encountered.'
return null
else
if !dt
return moment()
else if dt.isValid and dt.isValid()
return dt
if throws
throw 'Unknown date object encountered.'
return null

View File

@ -0,0 +1,425 @@
###*
Definition of the FRESHResume class.
@license MIT. See LICENSE.md for details.
@module core/fresh-resume
###
FS = require 'fs'
extend = require 'extend'
validator = require 'is-my-json-valid'
_ = require 'underscore'
__ = require 'lodash'
PATH = require 'path'
moment = require 'moment'
XML = require 'xml-escape'
MD = require 'marked'
CONVERTER = require 'fresh-jrs-converter'
JRSResume = require './jrs-resume'
###*
A FRESH resume or CV. FRESH resumes are backed by JSON, and each FreshResume
object is an instantiation of that JSON decorated with utility methods.
@constructor
###
class FreshResume
###* Initialize the FreshResume from file. ###
open: ( file, opts ) ->
raw = FS.readFileSync file, 'utf8'
ret = this.parse raw, opts
@imp.file = file
ret
###* Initialize the the FreshResume from JSON string data. ###
parse: ( stringData, opts ) ->
@imp = @imp ? raw: stringData
this.parseJSON JSON.parse( stringData ), opts
###*
Initialize the FreshResume from JSON.
Open and parse the specified FRESH resume. Merge the JSON object model onto
this Sheet instance with extend() and convert sheet dates to a safe &
consistent format. Then sort each section by startDate descending.
@param rep {Object} The raw JSON representation.
@param opts {Object} Resume loading and parsing options.
{
date: Perform safe date conversion.
sort: Sort resume items by date.
compute: Prepare computed resume totals.
}
###
parseJSON: ( rep, opts ) ->
# Ignore any element with the 'ignore: true' designator.
that = @
traverse = require 'traverse'
ignoreList = []
scrubbed = traverse( rep ).map ( x ) ->
if !@isLeaf && @node.ignore
if @node.ignore == true || this.node.ignore == 'true'
ignoreList.push this.node
@remove()
# Now apply the resume representation onto this object
extend( true, @, scrubbed );
# If the resume has already been processed, then we are being called from
# the .dupe method, and there's no need to do any post processing
if !@imp?.processed
# Set up metadata TODO: Clean up metadata on the object model.
opts = opts || { }
if opts.imp == undefined || opts.imp
@imp = @imp || { }
@imp.title = (opts.title || @imp.title) || @name
unless @imp.raw
@imp.raw = JSON.stringify rep
@imp.processed = true
# Parse dates, sort dates, and calculate computed values
(opts.date == undefined || opts.date) && _parseDates.call( this );
(opts.sort == undefined || opts.sort) && this.sort();
(opts.compute == undefined || opts.compute) && (@computed = {
numYears: this.duration(),
keywords: this.keywords()
});
@
###* Save the sheet to disk (for environments that have disk access). ###
save: ( filename ) ->
@imp.file = filename || @imp.file
FS.writeFileSync @imp.file, @stringify(), 'utf8'
@
###*
Save the sheet to disk in a specific format, either FRESH or JSON Resume.
###
saveAs: ( filename, format ) ->
if format != 'JRS'
@imp.file = filename || @imp.file
FS.writeFileSync @imp.file, @stringify(), 'utf8'
else
newRep = CONVERTER.toJRS this
FS.writeFileSync filename, JRSResume.stringify( newRep ), 'utf8'
@
###*
Duplicate this FreshResume instance.
This method first extend()s this object onto an empty, creating a deep copy,
and then passes the result into a new FreshResume instance via .parseJSON.
We do it this way to create a true clone of the object without re-running any
of the associated processing.
###
dupe: () ->
jso = extend true, { }, @
rnew = new FreshResume()
rnew.parseJSON jso, { }
rnew
###*
Convert this object to a JSON string, sanitizing meta-properties along the
way.
###
stringify: () -> FreshResume.stringify @
###*
Create a copy of this resume in which all string fields have been run through
a transformation function (such as a Markdown filter or XML encoder).
TODO: Move this out of FRESHResume.
###
transformStrings: ( filt, transformer ) ->
ret = this.dupe()
trx = require '../utils/string-transformer'
trx ret, filt, transformer
###*
Create a copy of this resume in which all fields have been interpreted as
Markdown.
###
markdownify: () ->
MDIN = ( txt ) ->
return MD(txt || '' ).replace(/^\s*<p>|<\/p>\s*$/gi, '')
trx = ( key, val ) ->
if key == 'summary'
return MD val
MDIN(val)
return @transformStrings ['skills','url','start','end','date'], trx
###*
Create a copy of this resume in which all fields have been interpreted as
Markdown.
###
xmlify: () ->
trx = (key, val) -> XML val
return @transformStrings [], trx
###* Return the resume format. ###
format: () -> 'FRESH'
###*
Return internal metadata. Create if it doesn't exist.
###
i: () -> this.imp = this.imp || { }
###* Return a unique list of all keywords across all skills. ###
keywords: () ->
flatSkills = []
if @skills
if @skills.sets
flatSkills = @skills.sets.map((sk) -> sk.skills ).reduce( (a,b) -> a.concat(b) )
else if @skills.list
flatSkills = flatSkills.concat( this.skills.list.map (sk) -> return sk.name )
flatSkills = _.uniq flatSkills
flatSkills
###*
Reset the sheet to an empty state. TODO: refactor/review
###
clear: ( clearMeta ) ->
clearMeta = ((clearMeta == undefined) && true) || clearMeta
delete this.imp if clearMeta
delete this.computed # Don't use Object.keys() here
delete this.employment
delete this.service
delete this.education
delete this.recognition
delete this.reading
delete this.writing
delete this.interests
delete this.skills
delete this.social
###*
Get a safe count of the number of things in a section.
###
count: ( obj ) ->
return 0 if !obj
return obj.history.length if obj.history
return obj.sets.length if obj.sets
obj.length || 0;
###* Add work experience to the sheet. ###
add: ( moniker ) ->
defSheet = FreshResume.default()
newObject =
if defSheet[moniker].history
then $.extend( true, {}, defSheet[ moniker ].history[0] )
else
if moniker == 'skills'
then $.extend( true, {}, defSheet.skills.sets[0] )
else $.extend( true, {}, defSheet[ moniker ][0] )
@[ moniker ] = @[ moniker ] || []
if @[ moniker ].history
@[ moniker ].history.push newObject
else if moniker == 'skills'
@skills.sets.push newObject
else
@[ moniker ].push newObject
newObject
###*
Determine if the sheet includes a specific social profile (eg, GitHub).
###
hasProfile: ( socialNetwork ) ->
socialNetwork = socialNetwork.trim().toLowerCase()
@social && _.some @social, (p) ->
p.network.trim().toLowerCase() == socialNetwork
###* Return the specified network profile. ###
getProfile: ( socialNetwork ) ->
socialNetwork = socialNetwork.trim().toLowerCase()
@social && _.find @social, (sn) ->
sn.network.trim().toLowerCase() == socialNetwork
###*
Return an array of profiles for the specified network, for when the user
has multiple eg. GitHub accounts.
###
getProfiles: ( socialNetwork ) ->
socialNetwork = socialNetwork.trim().toLowerCase()
@social && _.filter @social, (sn) ->
sn.network.trim().toLowerCase() == socialNetwork
###* Determine if the sheet includes a specific skill. ###
hasSkill: ( skill ) ->
skill = skill.trim().toLowerCase()
@skills && _.some @skills, (sk) ->
sk.keywords && _.some sk.keywords, (kw) ->
kw.trim().toLowerCase() == skill
###* Validate the sheet against the FRESH Resume schema. ###
isValid: ( info ) ->
schemaObj = require 'fresca'
validator = require 'is-my-json-valid'
validate = validator( schemaObj, { # See Note [1].
formats: { date: /^\d{4}(?:-(?:0[0-9]{1}|1[0-2]{1})(?:-[0-9]{2})?)?$/ }
})
ret = validate @
if !ret
this.imp = this.imp || { };
this.imp.validationErrors = validate.errors;
ret
###*
Calculate the total duration of the sheet. Assumes this.work has been sorted
by start date descending, perhaps via a call to Sheet.sort().
@returns The total duration of the sheet's work history, that is, the number
of years between the start date of the earliest job on the resume and the
*latest end date of all jobs in the work history*. This last condition is for
sheets that have overlapping jobs.
###
duration: (unit) ->
unit = unit || 'years'
empHist = __.get(this, 'employment.history')
if empHist && empHist.length
firstJob = _.last( empHist )
careerStart = if firstJob.start then firstJob.safe.start else ''
if ((typeof careerStart == 'string' || careerStart instanceof String) && !careerStart.trim())
return 0
careerLast = _.max empHist, ( w ) ->
return if w.safe && w.safe.end then w.safe.end.unix() else moment().unix()
return careerLast.safe.end.diff careerStart, unit
0
###*
Sort dated things on the sheet by start date descending. Assumes that dates
on the sheet have been processed with _parseDates().
###
sort: () ->
byDateDesc = (a,b) ->
if ( a.safe.start.isBefore(b.safe.start) )
then 1
else ( a.safe.start.isAfter(b.safe.start) && -1 ) || 0
sortSection = ( key ) ->
ar = __.get this, key
if ar && ar.length
datedThings = obj.filter (o) -> o.start
datedThings.sort( byDateDesc );
sortSection 'employment.history'
sortSection 'education.history'
sortSection 'service.history'
sortSection 'projects'
# this.awards && this.awards.sort( function(a, b) {
# return( a.safeDate.isBefore(b.safeDate) ) ? 1
# : ( a.safeDate.isAfter(b.safeDate) && -1 ) || 0;
# });
@writing && @writing.sort (a, b) ->
if a.safe.date.isBefore b.safe.date
then 1
else ( a.safe.date.isAfter(b.safe.date) && -1 ) || 0
###*
Get the default (starter) sheet.
###
FreshResume.default = () ->
new FreshResume().parseJSON( require 'fresh-resume-starter' )
###*
Convert the supplied FreshResume to a JSON string, sanitizing meta-properties
along the way.
###
FreshResume.stringify = ( obj ) ->
replacer = ( key,value ) -> # Exclude these keys from stringification
exKeys = ['imp', 'warnings', 'computed', 'filt', 'ctrl', 'index',
'safe', 'result', 'isModified', 'htmlPreview', 'display_progress_bar']
return if _.some( exKeys, (val) -> key.trim() == val )
then undefined else value
JSON.stringify obj, replacer, 2
###*
Convert human-friendly dates into formal Moment.js dates for all collections.
We don't want to lose the raw textual date as entered by the user, so we store
the Moment-ified date as a separate property with a prefix of .safe. For ex:
job.startDate is the date as entered by the user. job.safeStartDate is the
parsed Moment.js date that we actually use in processing.
###
_parseDates = () ->
_fmt = require('./fluent-date').fmt
that = @
# TODO: refactor recursion
replaceDatesInObject = ( obj ) ->
return if !obj
if Object.prototype.toString.call( obj ) == '[object Array]'
obj.forEach (elem) -> replaceDatesInObject( elem )
else if typeof obj == 'object'
if obj._isAMomentObject || obj.safe
return
Object.keys( obj ).forEach (key) -> replaceDatesInObject obj[key]
['start','end','date'].forEach (val) ->
if (obj[val] != undefined) && (!obj.safe || !obj.safe[val])
obj.safe = obj.safe || { }
obj.safe[ val ] = _fmt obj[val]
if obj[val] && (val == 'start') && !obj.end
obj.safe.end = _fmt 'current'
Object.keys( this ).forEach (member) -> replaceDatesInObject(that[member])
###* Export the Sheet function/ctor. ###
module.exports = FreshResume
# Note 1: Adjust default date validation to allow YYYY and YYYY-MM formats
# in addition to YYYY-MM-DD. The original regex:
#
# /^\d{4}-(?:0[0-9]{1}|1[0-2]{1})-[0-9]{2}$/
#

277
src/core/fresh-theme.coffee Normal file
View File

@ -0,0 +1,277 @@
###*
Definition of the FRESHTheme class.
@module core/fresh-theme
@license MIT. See LICENSE.md for details.
###
FS = require 'fs'
validator = require 'is-my-json-valid'
_ = require 'underscore'
PATH = require 'path'
parsePath = require 'parse-filepath'
pathExists = require('path-exists').sync
EXTEND = require 'extend'
HMSTATUS = require './status-codes'
moment = require 'moment'
loadSafeJson = require '../utils/safe-json-loader'
READFILES = require 'recursive-readdir-sync'
###
The FRESHTheme class is a representation of a FRESH theme
asset. See also: JRSTheme.
@class FRESHTheme
###
class FRESHTheme
###
Open and parse the specified theme.
###
open: ( themeFolder ) ->
this.folder = themeFolder;
# Open the [theme-name].json file; should have the same name as folder
pathInfo = parsePath( themeFolder )
# Set up a formats hash for the theme
formatsHash = { }
# Load the theme
themeFile = PATH.join( themeFolder, 'theme.json' )
themeInfo = loadSafeJson( themeFile )
if themeInfo.ex
throw
fluenterror:
if themeInfo.ex.operation == 'parse'
then HMSTATUS.parseError
else HMSTATUS.readError
inner: themeInfo.ex.inner
that = this
# Move properties from the theme JSON file to the theme object
EXTEND true, @, themeInfo.json
# Check for an "inherits" entry in the theme JSON.
if @inherits
cached = { }
_.each @inherits, (th, key) ->
themesFolder = require.resolve 'fresh-themes'
d = parsePath( themeFolder ).dirname
themePath = PATH.join d, th
cached[ th ] = cached[th] || new FRESHTheme().open( themePath )
formatsHash[ key ] = cached[ th ].getFormat( key )
# Check for an explicit "formats" entry in the theme JSON. If it has one,
# then this theme declares its files explicitly.
if !!@formats
formatsHash = loadExplicit.call this, formatsHash
@explicit = true;
else
formatsHash = loadImplicit.call this, formatsHash
# Cache
@formats = formatsHash
# Set the official theme name
@name = parsePath( @folder ).name
@
### Determine if the theme supports the specified output format. ###
hasFormat: ( fmt ) -> _.has @formats, fmt
### Determine if the theme supports the specified output format. ###
getFormat: ( fmt ) -> @formats[ fmt ]
### Load the theme implicitly, by scanning the theme folder for files. TODO:
Refactor duplicated code with loadExplicit. ###
loadImplicit = (formatsHash) ->
# Set up a hash of formats supported by this theme.
that = @
major = false
# Establish the base theme folder
tplFolder = PATH.join @folder, 'src'
# Iterate over all files in the theme folder, producing an array, fmts,
# containing info for each file. While we're doing that, also build up
# the formatsHash object.
fmts = READFILES(tplFolder).map (absPath) ->
# If this file lives in a specific format folder within the theme,
# such as "/latex" or "/html", then that format is the output format
# for all files within the folder.
pathInfo = parsePath absPath
outFmt = ''
isMajor = false
portion = pathInfo.dirname.replace tplFolder,''
if portion && portion.trim()
return if portion[1] == '_'
reg = /^(?:\/|\\)(html|latex|doc|pdf|png|partials)(?:\/|\\)?/ig
res = reg.exec( portion )
if res
if res[1] != 'partials'
outFmt = res[1]
else
that.partials = that.partials || []
that.partials.push( { name: pathInfo.name, path: absPath } )
return null
# Otherwise, the output format is inferred from the filename, as in
# compact-[outputformat].[extension], for ex, compact-pdf.html.
if !outFmt
idx = pathInfo.name.lastIndexOf '-'
outFmt = if idx == -1 then pathInfo.name else pathInfo.name.substr( idx + 1 )
isMajor = true
# We should have a valid output format now.
formatsHash[ outFmt ] = formatsHash[outFmt] || {
outFormat: outFmt,
files: []
}
# Create the file representation object.
obj =
action: 'transform'
path: absPath
major: isMajor
orgPath: PATH.relative(tplFolder, absPath)
ext: pathInfo.extname.slice(1)
title: friendlyName( outFmt )
pre: outFmt
# outFormat: outFmt || pathInfo.name,
data: FS.readFileSync( absPath, 'utf8' )
css: null
# Add this file to the list of files for this format type.
formatsHash[ outFmt ].files.push( obj )
obj
# Now, get all the CSS files...
@cssFiles = fmts.filter (fmt) -> fmt and (fmt.ext == 'css')
# For each CSS file, get its corresponding HTML file. It's possible that
# a theme can have a CSS file but *no* HTML file, as when a theme author
# creates a pure CSS override of an existing theme.
@cssFiles.forEach (cssf) ->
idx = _.findIndex fmts, ( fmt ) ->
fmt && fmt.pre == cssf.pre && fmt.ext == 'html'
cssf.major = false
if idx > -1
fmts[ idx ].css = cssf.data
fmts[ idx ].cssPath = cssf.path
else
if that.inherits
# Found a CSS file without an HTML file in a theme that inherits
# from another theme. This is the override CSS file.
that.overrides = { file: cssf.path, data: cssf.data }
formatsHash
###
Load the theme explicitly, by following the 'formats' hash
in the theme's JSON settings file.
###
loadExplicit = (formatsHash) ->
# Housekeeping
tplFolder = PATH.join this.folder, 'src'
act = null
that = this
# Iterate over all files in the theme folder, producing an array, fmts,
# containing info for each file. While we're doing that, also build up
# the formatsHash object.
fmts = READFILES( tplFolder ).map (absPath) ->
act = null
# If this file is mentioned in the theme's JSON file under "transforms"
pathInfo = parsePath(absPath)
absPathSafe = absPath.trim().toLowerCase()
outFmt = _.find Object.keys( that.formats ), ( fmtKey ) ->
fmtVal = that.formats[ fmtKey ]
_.some fmtVal.transform, (fpath) ->
absPathB = PATH.join( that.folder, fpath ).trim().toLowerCase()
absPathB == absPathSafe
act = 'transform' if outFmt
# If this file lives in a specific format folder within the theme,
# such as "/latex" or "/html", then that format is the output format
# for all files within the folder.
if !outFmt
portion = pathInfo.dirname.replace tplFolder,''
if portion && portion.trim()
reg = /^(?:\/|\\)(html|latex|doc|pdf)(?:\/|\\)?/ig
res = reg.exec portion
res && (outFmt = res[1])
# Otherwise, the output format is inferred from the filename, as in
# compact-[outputformat].[extension], for ex, compact-pdf.html.
if !outFmt
idx = pathInfo.name.lastIndexOf '-'
outFmt = if (idx == -1) then pathInfo.name else pathInfo.name.substr(idx + 1)
# We should have a valid output format now.
formatsHash[ outFmt ] =
formatsHash[ outFmt ] || {
outFormat: outFmt,
files: [],
symLinks: that.formats[ outFmt ].symLinks
};
# Create the file representation object.
obj =
action: act
orgPath: PATH.relative(that.folder, absPath)
path: absPath
ext: pathInfo.extname.slice(1)
title: friendlyName( outFmt )
pre: outFmt
# outFormat: outFmt || pathInfo.name,
data: FS.readFileSync( absPath, 'utf8' )
css: null
# Add this file to the list of files for this format type.
formatsHash[ outFmt ].files.push( obj )
obj
# Now, get all the CSS files...
@cssFiles = fmts.filter ( fmt ) -> fmt.ext == 'css'
# For each CSS file, get its corresponding HTML file
@cssFiles.forEach ( cssf ) ->
# For each CSS file, get its corresponding HTML file
idx = _.findIndex fmts, ( fmt ) ->
fmt.pre == cssf.pre && fmt.ext == 'html'
fmts[ idx ].css = cssf.data;
fmts[ idx ].cssPath = cssf.path;
# Remove CSS files from the formats array
fmts = fmts.filter ( fmt) -> fmt.ext != 'css'
formatsHash
###
Return a more friendly name for certain formats.
TODO: Refactor
###
friendlyName = ( val ) ->
val = val.trim().toLowerCase()
friendly = { yml: 'yaml', md: 'markdown', txt: 'text' }
friendly[val] || val
module.exports = FRESHTheme

360
src/core/jrs-resume.coffee Normal file
View File

@ -0,0 +1,360 @@
###*
Definition of the JRSResume class.
@license MIT. See LICENSE.md for details.
@module core/jrs-resume
###
FS = require('fs')
extend = require('extend')
validator = require('is-my-json-valid')
_ = require('underscore')
PATH = require('path')
MD = require('marked')
CONVERTER = require('fresh-jrs-converter')
moment = require('moment')
###*
A JRS resume or CV. JRS resumes are backed by JSON, and each JRSResume object
is an instantiation of that JSON decorated with utility methods.
@class JRSResume
###
class JRSResume
###* Initialize the JSResume from file. ###
open: ( file, opts ) ->
raw = FS.readFileSync file, 'utf8'
ret = this.parse raw, opts
@imp.file = file
ret
###* Initialize the the JSResume from string. ###
parse: ( stringData, opts ) ->
@imp = @imp ? raw: stringData
this.parseJSON JSON.parse( stringData ), opts
###*
Initialize the JRSResume object from JSON.
Open and parse the specified JRS resume. Merge the JSON object model onto
this Sheet instance with extend() and convert sheet dates to a safe &
consistent format. Then sort each section by startDate descending.
@param rep {Object} The raw JSON representation.
@param opts {Object} Resume loading and parsing options.
{
date: Perform safe date conversion.
sort: Sort resume items by date.
compute: Prepare computed resume totals.
}
###
parseJSON: ( rep, opts ) ->
opts = opts || { };
# Ignore any element with the 'ignore: true' designator.
that = this
traverse = require 'traverse'
ignoreList = []
scrubbed = traverse( rep ).map ( x ) ->
if !@isLeaf && @node.ignore
if @node.ignore == true || this.node.ignore == 'true'
ignoreList.push @node
@remove()
# Extend resume properties onto ourself.
extend true, this, scrubbed
# Set up metadata
if !@imp?.processed
# Set up metadata TODO: Clean up metadata on the object model.
opts = opts || { }
if opts.imp == undefined || opts.imp
@imp = @imp || { }
@imp.title = (opts.title || @imp.title) || @basics.name
unless @imp.raw
@imp.raw = JSON.stringify rep
@imp.processed = true
# Parse dates, sort dates, and calculate computed values
(opts.date == undefined || opts.date) && _parseDates.call( this )
(opts.sort == undefined || opts.sort) && this.sort()
if opts.compute == undefined || opts.compute
@basics.computed =
numYears: this.duration()
keywords: this.keywords()
@
###* Save the sheet to disk (for environments that have disk access). ###
save: ( filename ) ->
@imp.file = filename || @imp.file
FS.writeFileSync @imp.file, @stringify( this ), 'utf8'
@
###* Save the sheet to disk in a specific format, either FRESH or JRS. ###
saveAs: ( filename, format ) ->
if format == 'JRS'
@imp.file = filename || @imp.file;
FS.writeFileSync( @imp.file, @stringify(), 'utf8' );
else
newRep = CONVERTER.toFRESH @
stringRep = CONVERTER.toSTRING newRep
FS.writeFileSync filename, stringRep, 'utf8'
@
###* Return the resume format. ###
format = () -> 'JRS'
stringify: () -> JRSResume.stringify( @ )
###* Return a unique list of all keywords across all skills. ###
keywords: () ->
flatSkills = []
if @skills && this.skills.length
@skills.forEach ( s ) -> flatSkills = _.union flatSkills, s.keywords
flatSkills
###*
Return internal metadata. Create if it doesn't exist.
JSON Resume v0.0.0 doesn't allow additional properties at the root level,
so tuck this into the .basic sub-object.
###
i: () ->
@imp = @imp ? { }
###* Reset the sheet to an empty state. ###
clear = ( clearMeta ) ->
clearMeta = ((clearMeta == undefined) && true) || clearMeta;
delete this.imp if clearMeta
delete this.basics.computed # Don't use Object.keys() here
delete this.work
delete this.volunteer
delete this.education
delete this.awards
delete this.publications
delete this.interests
delete this.skills
delete this.basics.profiles
###* Add work experience to the sheet. ###
add: ( moniker ) ->
defSheet = JRSResume.default()
newObject = $.extend( true, {}, defSheet[ moniker ][0] )
this[ moniker ] = this[ moniker ] || []
this[ moniker ].push( newObject )
newObject
###* Determine if the sheet includes a specific social profile (eg, GitHub). ###
hasProfile: ( socialNetwork ) ->
socialNetwork = socialNetwork.trim().toLowerCase()
return @basics.profiles && _.some @basics.profiles, (p) ->
return p.network.trim().toLowerCase() == socialNetwork
###* Determine if the sheet includes a specific skill. ###
hasSkill: ( skill ) ->
skill = skill.trim().toLowerCase()
return this.skills && _.some this.skills, (sk) ->
return sk.keywords && _.some sk.keywords, (kw) ->
kw.trim().toLowerCase() == skill
###* Validate the sheet against the JSON Resume schema. ###
isValid: ( ) -> # TODO: ↓ fix this path ↓
schema = FS.readFileSync PATH.join( __dirname, 'resume.json' ), 'utf8'
schemaObj = JSON.parse schema
validator = require 'is-my-json-valid'
validate = validator( schemaObj, { # Note [1]
formats: { date: /^\d{4}(?:-(?:0[0-9]{1}|1[0-2]{1})(?:-[0-9]{2})?)?$/ }
});
temp = @imp
delete @imp
ret = validate @
@imp = temp
if !ret
@imp = @imp || { };
@imp.validationErrors = validate.errors;
ret
###*
Calculate the total duration of the sheet. Assumes this.work has been sorted
by start date descending, perhaps via a call to Sheet.sort().
@returns The total duration of the sheet's work history, that is, the number
of years between the start date of the earliest job on the resume and the
*latest end date of all jobs in the work history*. This last condition is for
sheets that have overlapping jobs.
###
duration: ( unit ) ->
unit = unit || 'years';
if this.work && this.work.length
careerStart = this.work[ this.work.length - 1].safeStartDate
if (typeof careerStart == 'string' || careerStart instanceof String) && !careerStart.trim()
return 0
careerLast = _.max( this.work, ( w ) -> w.safeEndDate.unix() ).safeEndDate;
return careerLast.diff careerStart, unit
0
###*
Sort dated things on the sheet by start date descending. Assumes that dates
on the sheet have been processed with _parseDates().
###
sort: ( ) ->
byDateDesc = (a,b) ->
if a.safeStartDate.isBefore(b.safeStartDate)
then 1
else ( a.safeStartDate.isAfter(b.safeStartDate) && -1 ) || 0
@work && @work.sort byDateDesc
@education && @education.sort byDateDesc
@volunteer && @volunteer.sort byDateDesc
@awards && @awards.sort (a, b) ->
if a.safeDate.isBefore b.safeDate
then 1
else (a.safeDate.isAfter(b.safeDate) && -1 ) || 0;
@publications && @publications.sort (a, b) ->
if ( a.safeReleaseDate.isBefore(b.safeReleaseDate) )
then 1
else ( a.safeReleaseDate.isAfter(b.safeReleaseDate) && -1 ) || 0
dupe: () ->
rnew = new JRSResume()
rnew.parse this.stringify(), { }
rnew
###*
Create a copy of this resume in which all fields have been interpreted as
Markdown.
###
harden: () ->
that = @
ret = @dupe()
HD = (txt) -> '@@@@~' + txt + '~@@@@'
HDIN = (txt) ->
#return MD(txt || '' ).replace(/^\s*<p>|<\/p>\s*$/gi, '');
return HD txt
# TODO: refactor recursion
hardenStringsInObject = ( obj, inline ) ->
return if !obj
inline = inline == undefined || inline
if Object.prototype.toString.call( obj ) == '[object Array]'
obj.forEach (elem, idx, ar) ->
if typeof elem == 'string' || elem instanceof String
ar[idx] = if inline then HDIN(elem) else HD( elem )
else
hardenStringsInObject elem
else if typeof obj == 'object'
Object.keys( obj ).forEach (key) ->
sub = obj[key]
if typeof sub == 'string' || sub instanceof String
if _.contains(['skills','url','website','startDate','endDate',
'releaseDate','date','phone','email','address','postalCode',
'city','country','region'], key)
return
if key == 'summary'
obj[key] = HD( obj[key] )
else
obj[key] = if inline then HDIN( obj[key] ) else HD( obj[key] )
else
hardenStringsInObject sub
Object.keys( ret ).forEach (member) ->
hardenStringsInObject ret[ member ]
ret
###* Get the default (empty) sheet. ###
JRSResume.default = () ->
new JRSResume().open PATH.join( __dirname, 'empty-jrs.json'), 'Empty'
###*
Convert this object to a JSON string, sanitizing meta-properties along the
way. Don't override .toString().
###
JRSResume.stringify = ( obj ) ->
replacer = ( key,value ) -> # Exclude these keys from stringification
temp = _.some ['imp', 'warnings', 'computed', 'filt', 'ctrl', 'index',
'safeStartDate', 'safeEndDate', 'safeDate', 'safeReleaseDate', 'result',
'isModified', 'htmlPreview', 'display_progress_bar'],
( val ) -> return key.trim() == val
return if temp then undefined else value
JSON.stringify obj, replacer, 2
###*
Convert human-friendly dates into formal Moment.js dates for all collections.
We don't want to lose the raw textual date as entered by the user, so we store
the Moment-ified date as a separate property with a prefix of .safe. For ex:
job.startDate is the date as entered by the user. job.safeStartDate is the
parsed Moment.js date that we actually use in processing.
###
_parseDates = () ->
_fmt = require('./fluent-date').fmt
@work && @work.forEach (job) ->
job.safeStartDate = _fmt( job.startDate )
job.safeEndDate = _fmt( job.endDate )
@education && @education.forEach (edu) ->
edu.safeStartDate = _fmt( edu.startDate )
edu.safeEndDate = _fmt( edu.endDate )
@volunteer && @volunteer.forEach (vol) ->
vol.safeStartDate = _fmt( vol.startDate )
vol.safeEndDate = _fmt( vol.endDate )
@awards && @awards.forEach (awd) ->
awd.safeDate = _fmt( awd.date )
@publications && @publications.forEach (pub) ->
pub.safeReleaseDate = _fmt( pub.releaseDate )
###*
Export the JRSResume function/ctor.
###
module.exports = JRSResume

87
src/core/jrs-theme.coffee Normal file
View File

@ -0,0 +1,87 @@
###*
Definition of the JRSTheme class.
@module core/jrs-theme
@license MIT. See LICENSE.MD for details.
###
_ = require 'underscore'
PATH = require 'path'
parsePath = require 'parse-filepath'
pathExists = require('path-exists').sync
###*
The JRSTheme class is a representation of a JSON Resume theme asset.
@class JRSTheme
###
class JRSTheme
###*
Open and parse the specified theme.
@method open
###
open: ( thFolder ) ->
@folder = thFolder
# Open the [theme-name].json file; should have the same
# name as folder
pathInfo = parsePath thFolder
# Open and parse the theme's package.json file.
pkgJsonPath = PATH.join thFolder, 'package.json'
if pathExists pkgJsonPath
thApi = require thFolder
thPkg = require pkgJsonPath
this.name = thPkg.name
this.render = (thApi && thApi.render) || undefined
this.engine = 'jrs'
# Create theme formats (HTML and PDF). Just add the bare minimum mix of
# properties necessary to allow JSON Resume themes to share a rendering
# path with FRESH themes.
this.formats =
html:
outFormat: 'html'
files: [{
action: 'transform',
render: this.render,
major: true,
ext: 'html',
css: null
}]
pdf:
outFormat: 'pdf'
files: [{
action: 'transform',
render: this.render,
major: true,
ext: 'pdf',
css: null
}]
else
throw { fluenterror: HACKMYSTATUS.missingPackageJSON };
@
###*
Determine if the theme supports the output format.
@method hasFormat
###
hasFormat: ( fmt ) -> _.has this.formats, fmt
###*
Return the requested output format.
@method getFormat
###
getFormat = ( fmt ) -> @formats[ fmt ]
module.exports = JRSTheme;

View File

@ -0,0 +1,115 @@
###*
Definition of the ResumeFactory class.
@license MIT. See LICENSE.md for details.
@module core/resume-factory
###
FS = require('fs')
HACKMYSTATUS = require('./status-codes')
HME = require('./event-codes')
ResumeConverter = require('fresh-jrs-converter')
chalk = require('chalk')
SyntaxErrorEx = require('../utils/syntax-error-ex')
_ = require('underscore')
require('string.prototype.startswith')
###*
A simple factory class for FRESH and JSON Resumes.
@class ResumeFactory
###
ResumeFactory = module.exports =
###*
Load one or more resumes from disk.
@param {Object} opts An options object with settings for the factory as well
as passthrough settings for FRESHResume or JRSResume. Structure:
{
format: 'FRESH', // Format to open as. ('FRESH', 'JRS', null)
objectify: true, // FRESH/JRSResume or raw JSON?
inner: { // Passthru options for FRESH/JRSResume
sort: false
}
}
###
load: ( sources, opts, emitter ) ->
sources.map( (src) ->
@loadOne( src, opts, emitter )
, @)
###* Load a single resume from disk. ###
loadOne: ( src, opts, emitter ) ->
toFormat = opts.format # Can be null
objectify = opts.objectify
# Get the destination format. Can be 'fresh', 'jrs', or null/undefined.
toFormat && (toFormat = toFormat.toLowerCase().trim())
# Load and parse the resume JSON
info = _parse src, opts, emitter
return info if info.fluenterror
# Determine the resume format: FRESH or JRS
json = info.json
isFRESH = json.meta && json.meta.format && json.meta.format.startsWith('FRESH@');
orgFormat = if isFRESH then 'fresh' else 'jrs'
# Convert between formats if necessary
if toFormat and ( orgFormat != toFormat )
json = ResumeConverter[ 'to' + toFormat.toUpperCase() ]( json )
# Objectify the resume, that is, convert it from JSON to a FRESHResume
# or JRSResume object.
rez = null
if objectify
ResumeClass = require('../core/' + (toFormat || orgFormat) + '-resume');
rez = new ResumeClass().parseJSON( json, opts.inner );
rez.i().file = src;
file: src
json: info.json
rez: rez
_parse = ( fileName, opts, eve ) ->
rawData = null
try
# Read the file
eve && eve.stat( HME.beforeRead, { file: fileName });
rawData = FS.readFileSync( fileName, 'utf8' );
eve && eve.stat( HME.afterRead, { file: fileName, data: rawData });
# Parse the file
eve && eve.stat HME.beforeParse, { data: rawData }
ret = { json: JSON.parse( rawData ) }
orgFormat =
if ret.json.meta && ret.json.meta.format && ret.json.meta.format.startsWith('FRESH@')
then 'fresh' else 'jrs'
eve && eve.stat HME.afterParse, { file: fileName, data: ret.json, fmt: orgFormat }
return ret
catch
# Can be ENOENT, EACCES, SyntaxError, etc.
ex =
fluenterror: if rawData then HACKMYSTATUS.parseError else HACKMYSTATUS.readError
inner: _error
raw: rawData
file: fileName
shouldExit: false
opts.quit && (ex.quit = true)
eve && eve.err ex.fluenterror, ex
throw ex if opts.throw
ex

380
src/core/resume.json Normal file
View File

@ -0,0 +1,380 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Resume Schema",
"type": "object",
"additionalProperties": false,
"properties": {
"basics": {
"type": "object",
"additionalProperties": true,
"properties": {
"name": {
"type": "string"
},
"label": {
"type": "string",
"description": "e.g. Web Developer"
},
"picture": {
"type": "string",
"description": "URL (as per RFC 3986) to a picture in JPEG or PNG format"
},
"email": {
"type": "string",
"description": "e.g. thomas@gmail.com",
"format": "email"
},
"phone": {
"type": "string",
"description": "Phone numbers are stored as strings so use any format you like, e.g. 712-117-2923"
},
"website": {
"type": "string",
"description": "URL (as per RFC 3986) to your website, e.g. personal homepage",
"format": "uri"
},
"summary": {
"type": "string",
"description": "Write a short 2-3 sentence biography about yourself"
},
"location": {
"type": "object",
"additionalProperties": true,
"properties": {
"address": {
"type": "string",
"description": "To add multiple address lines, use \n. For example, 1234 Glücklichkeit Straße\nHinterhaus 5. Etage li."
},
"postalCode": {
"type": "string"
},
"city": {
"type": "string"
},
"countryCode": {
"type": "string",
"description": "code as per ISO-3166-1 ALPHA-2, e.g. US, AU, IN"
},
"region": {
"type": "string",
"description": "The general region where you live. Can be a US state, or a province, for instance."
}
}
},
"profiles": {
"type": "array",
"description": "Specify any number of social networks that you participate in",
"additionalItems": false,
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"network": {
"type": "string",
"description": "e.g. Facebook or Twitter"
},
"username": {
"type": "string",
"description": "e.g. neutralthoughts"
},
"url": {
"type": "string",
"description": "e.g. http://twitter.com/neutralthoughts"
}
}
}
}
}
},
"work": {
"type": "array",
"additionalItems": false,
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"company": {
"type": "string",
"description": "e.g. Facebook"
},
"position": {
"type": "string",
"description": "e.g. Software Engineer"
},
"website": {
"type": "string",
"description": "e.g. http://facebook.com",
"format": "uri"
},
"startDate": {
"type": "string",
"description": "resume.json uses the ISO 8601 date standard e.g. 2014-06-29",
"format": "date"
},
"endDate": {
"type": "string",
"description": "e.g. 2012-06-29",
"format": "date"
},
"summary": {
"type": "string",
"description": "Give an overview of your responsibilities at the company"
},
"highlights": {
"type": "array",
"description": "Specify multiple accomplishments",
"additionalItems": false,
"items": {
"type": "string",
"description": "e.g. Increased profits by 20% from 2011-2012 through viral advertising"
}
}
}
}
},
"volunteer": {
"type": "array",
"additionalItems": false,
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"organization": {
"type": "string",
"description": "e.g. Facebook"
},
"position": {
"type": "string",
"description": "e.g. Software Engineer"
},
"website": {
"type": "string",
"description": "e.g. http://facebook.com",
"format": "uri"
},
"startDate": {
"type": "string",
"description": "resume.json uses the ISO 8601 date standard e.g. 2014-06-29",
"format": "date"
},
"endDate": {
"type": "string",
"description": "e.g. 2012-06-29",
"format": "date"
},
"summary": {
"type": "string",
"description": "Give an overview of your responsibilities at the company"
},
"highlights": {
"type": "array",
"description": "Specify multiple accomplishments",
"additionalItems": false,
"items": {
"type": "string",
"description": "e.g. Increased profits by 20% from 2011-2012 through viral advertising"
}
}
}
}
},
"education": {
"type": "array",
"additionalItems": false,
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"institution": {
"type": "string",
"description": "e.g. Massachusetts Institute of Technology"
},
"area": {
"type": "string",
"description": "e.g. Arts"
},
"studyType": {
"type": "string",
"description": "e.g. Bachelor"
},
"startDate": {
"type": "string",
"description": "e.g. 2014-06-29",
"format": "date"
},
"endDate": {
"type": "string",
"description": "e.g. 2012-06-29",
"format": "date"
},
"gpa": {
"type": "string",
"description": "grade point average, e.g. 3.67/4.0"
},
"courses": {
"type": "array",
"description": "List notable courses/subjects",
"additionalItems": false,
"items": {
"type": "string",
"description": "e.g. H1302 - Introduction to American history"
}
}
}
}
},
"awards": {
"type": "array",
"description": "Specify any awards you have received throughout your professional career",
"additionalItems": false,
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"title": {
"type": "string",
"description": "e.g. One of the 100 greatest minds of the century"
},
"date": {
"type": "string",
"description": "e.g. 1989-06-12",
"format": "date"
},
"awarder": {
"type": "string",
"description": "e.g. Time Magazine"
},
"summary": {
"type": "string",
"description": "e.g. Received for my work with Quantum Physics"
}
}
}
},
"publications": {
"type": "array",
"description": "Specify your publications through your career",
"additionalItems": false,
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"name": {
"type": "string",
"description": "e.g. The World Wide Web"
},
"publisher": {
"type": "string",
"description": "e.g. IEEE, Computer Magazine"
},
"releaseDate": {
"type": "string",
"description": "e.g. 1990-08-01"
},
"website": {
"type": "string",
"description": "e.g. http://www.computer.org/csdl/mags/co/1996/10/rx069-abs.html"
},
"summary": {
"type": "string",
"description": "Short summary of publication. e.g. Discussion of the World Wide Web, HTTP, HTML."
}
}
}
},
"skills": {
"type": "array",
"description": "List out your professional skill-set",
"additionalItems": false,
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"name": {
"type": "string",
"description": "e.g. Web Development"
},
"level": {
"type": "string",
"description": "e.g. Master"
},
"keywords": {
"type": "array",
"description": "List some keywords pertaining to this skill",
"additionalItems": false,
"items": {
"type": "string",
"description": "e.g. HTML"
}
}
}
}
},
"languages": {
"type": "array",
"description": "List any other languages you speak",
"additionalItems": false,
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"language": {
"type": "string",
"description": "e.g. English, Spanish"
},
"fluency": {
"type": "string",
"description": "e.g. Fluent, Beginner"
}
}
}
},
"interests": {
"type": "array",
"additionalItems": false,
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"name": {
"type": "string",
"description": "e.g. Philosophy"
},
"keywords": {
"type": "array",
"additionalItems": false,
"items": {
"type": "string",
"description": "e.g. Friedrich Nietzsche"
}
}
}
}
},
"references": {
"type": "array",
"description": "List references you have received",
"additionalItems": false,
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"name": {
"type": "string",
"description": "e.g. Timothy Cook"
},
"reference": {
"type": "string",
"description": "e.g. Joe blogs was a great employee, who turned up to work at least once a week. He exceeded my expectations when it came to doing nothing."
}
}
}
}
}
}

View File

@ -0,0 +1,33 @@
###*
Status codes for HackMyResume.
@module core/status-codes
@license MIT. See LICENSE.MD for details.
###
module.exports =
success: 0
themeNotFound: 1
copyCss: 2
resumeNotFound: 3
missingCommand: 4
invalidCommand: 5
resumeNotFoundAlt: 6
inputOutputParity: 7
createNameMissing: 8
pdfgeneration: 9
missingPackageJSON: 10
invalid: 11
invalidFormat: 12
notOnPath: 13
readError: 14
parseError: 15
fileSaveError: 16
generateError: 17
invalidHelperUse: 18
mixedMerge: 19
invokeTemplate: 20
compileTemplate: 21
themeLoad: 22
invalidParamCount: 23
missingParam: 24

View File

@ -0,0 +1,28 @@
###*
Definition of the BaseGenerator class.
@module base-generator.js
@license MIT. See LICENSE.md for details.
###
# Use J. Resig's nifty class implementation
Class = require '../utils/class'
###*
The BaseGenerator class is the root of the generator hierarchy. Functionality
common to ALL generators lives here.
###
BaseGenerator = module.exports = Class.extend
###* Base-class initialize. ###
init: ( outputFormat ) -> @format = outputFormat
###* Status codes. ###
codes: require '../core/status-codes'
###* Generator options. ###
opts: { }

View File

@ -0,0 +1,30 @@
###*
Definition of the HTMLGenerator class.
@license MIT. See LICENSE.md for details.
@module html-generator.js
###
TemplateGenerator = require './template-generator'
FS = require 'fs-extra'
HTML = require 'html'
PATH = require 'path'
require 'string.prototype.endswith'
HtmlGenerator = module.exports = TemplateGenerator.extend
init: -> @_super 'html'
###*
Copy satellite CSS files to the destination and optionally pretty-print
the HTML resume prior to saving.
###
onBeforeSave: ( info ) ->
if info.outputFile.endsWith '.css'
return info.mk
if @opts.prettify
then HTML.prettyPrint info.mk, this.opts.prettify
else info.mk

View File

@ -0,0 +1,91 @@
###*
Definition of the HtmlPdfCLIGenerator class.
@module html-pdf-generator.js
@license MIT. See LICENSE.md for details.
###
TemplateGenerator = require('./template-generator')
FS = require('fs-extra')
HTML = require( 'html' )
PATH = require('path')
SPAWN = require('../utils/safe-spawn')
SLASH = require('slash');
###*
An HTML-driven PDF resume generator for HackMyResume. Talks to Phantom,
wkhtmltopdf, and other PDF engines over a CLI (command-line interface).
If an engine isn't installed for a particular platform, error out gracefully.
###
HtmlPdfCLIGenerator = module.exports = TemplateGenerator.extend
init: () -> @_super 'pdf', 'html'
###* Generate the binary PDF. ###
onBeforeSave: ( info ) ->
try
safe_eng = info.opts.pdf || 'wkhtmltopdf';
if safe_eng != 'none'
engines[ safe_eng ].call this, info.mk, info.outputFile
return null # halt further processing
catch ex
# { [Error: write EPIPE] code: 'EPIPE', errno: 'EPIPE', ... }
# { [Error: ENOENT] }
if ex.inner && ex.inner.code == 'ENOENT'
throw
fluenterror: this.codes.notOnPath
inner: ex.inner
engine: ex.cmd,
stack: ex.inner && ex.inner.stack
else
throw
fluenterror: this.codes.pdfGeneration
inner: ex
stack: ex.stack
# TODO: Move each engine to a separate module
engines =
###*
Generate a PDF from HTML using wkhtmltopdf's CLI interface.
Spawns a child process with `wkhtmltopdf <source> <target>`. wkhtmltopdf
must be installed and path-accessible.
TODO: If HTML generation has run, reuse that output
TODO: Local web server to ease wkhtmltopdf rendering
###
wkhtmltopdf: (markup, fOut) ->
# Save the markup to a temporary file
tempFile = fOut.replace /\.pdf$/i, '.pdf.html'
FS.writeFileSync tempFile, markup, 'utf8'
info = SPAWN 'wkhtmltopdf', [ tempFile, fOut ]
###*
Generate a PDF from HTML using Phantom's CLI interface.
Spawns a child process with `phantomjs <script> <source> <target>`. Phantom
must be installed and path-accessible.
TODO: If HTML generation has run, reuse that output
TODO: Local web server to ease Phantom rendering
###
phantom: ( markup, fOut ) ->
# Save the markup to a temporary file
tempFile = fOut.replace(/\.pdf$/i, '.pdf.html');
FS.writeFileSync tempFile, markup, 'utf8'
scriptPath = SLASH( PATH.relative( process.cwd(),
PATH.resolve( __dirname, '../utils/rasterize.js' ) ) );
sourcePath = SLASH( PATH.relative( process.cwd(), tempFile) );
destPath = SLASH( PATH.relative( process.cwd(), fOut) );
info = SPAWN('phantomjs', [ scriptPath, sourcePath, destPath ]);

View File

@ -0,0 +1,52 @@
###*
Definition of the HtmlPngGenerator class.
@license MIT. See LICENSE.MD for details.
@module html-png-generator.js
###
TemplateGenerator = require './template-generator'
FS = require 'fs-extra'
HTML = require 'html'
SLASH = require 'slash'
SPAWN = require '../utils/safe-spawn'
PATH = require 'path'
###*
An HTML-based PNG resume generator for HackMyResume.
###
HtmlPngGenerator = module.exports = TemplateGenerator.extend
init: -> @_super 'png', 'html'
invoke: ( rez, themeMarkup, cssInfo, opts ) ->
# TODO: Not currently called or callable.
generate: ( rez, f, opts ) ->
htmlResults = opts.targets.filter (t) -> t.fmt.outFormat == 'html'
htmlFile = htmlResults[0].final.files.filter (fl) ->
fl.info.ext == 'html'
phantom htmlFile[0].data, f
return
###*
Generate a PDF from HTML using Phantom's CLI interface.
Spawns a child process with `phantomjs <script> <source> <target>`. Phantom
must be installed and path-accessible.
TODO: If HTML generation has run, reuse that output
TODO: Local web server to ease Phantom rendering
###
phantom = ( markup, fOut ) ->
# Save the markup to a temporary file
tempFile = fOut.replace(/\.png$/i, '.png.html');
FS.writeFileSync tempFile, markup, 'utf8'
scriptPath = SLASH( PATH.relative( process.cwd(),
PATH.resolve( __dirname, '../utils/rasterize.js' ) ) );
sourcePath = SLASH( PATH.relative( process.cwd(), tempFile) );
destPath = SLASH( PATH.relative( process.cwd(), fOut) );
info = SPAWN('phantomjs', [ scriptPath, sourcePath, destPath ]);
return

View File

@ -0,0 +1,35 @@
###*
Definition of the JsonGenerator class.
@license MIT. See LICENSE.md for details.
@module generators/json-generator
###
BaseGenerator = require './base-generator'
FS = require 'fs'
_ = require 'underscore'
###*
The JsonGenerator generates a JSON resume directly.
###
JsonGenerator = module.exports = BaseGenerator.extend
init: () -> @_super 'json'
keys: ['imp', 'warnings', 'computed', 'filt', 'ctrl', 'index',
'safeStartDate', 'safeEndDate', 'safeDate', 'safeReleaseDate', 'result',
'isModified', 'htmlPreview', 'safe' ]
invoke: ( rez ) ->
# TODO: merge with FCVD
replacer = ( key,value ) -> # Exclude these keys from stringification
if (_.some @keys, (val) -> key.trim() == val)
return undefined
else
value
JSON.stringify rez, replacer, 2
generate: ( rez, f ) ->
FS.writeFileSync( f, this.invoke(rez), 'utf8' )
return

View File

@ -0,0 +1,30 @@
###*
Definition of the JsonYamlGenerator class.
@module json-yaml-generator.js
@license MIT. See LICENSE.md for details.
###
BaseGenerator = require('./base-generator')
FS = require('fs')
YAML = require('yamljs')
###*
JsonYamlGenerator takes a JSON resume object and translates it directly to
JSON without a template, producing an equivalent YAML-formatted resume. See
also YamlGenerator (yaml-generator.js).
###
JsonYamlGenerator = module.exports = BaseGenerator.extend
init: () -> @_super 'yml'
invoke: ( rez, themeMarkup, cssInfo, opts ) ->
YAML.stringify JSON.parse( rez.stringify() ), Infinity, 2
generate: ( rez, f, opts ) ->
data = YAML.stringify JSON.parse( rez.stringify() ), Infinity, 2
FS.writeFileSync f, data, 'utf8'

View File

@ -0,0 +1,14 @@
###*
Definition of the LaTeXGenerator class.
@license MIT. See LICENSE.md for details.
@module generators/latex-generator
###
TemplateGenerator = require './template-generator'
###*
LaTeXGenerator generates a LaTeX resume via TemplateGenerator.
###
LaTeXGenerator = module.exports = TemplateGenerator.extend
init: () -> @_super 'latex', 'tex'

View File

@ -0,0 +1,14 @@
###*
Definition of the MarkdownGenerator class.
@license MIT. Copyright (c) 2015 James Devlin / FluentDesk.
@module markdown-generator.js
###
TemplateGenerator = require './template-generator'
###*
MarkdownGenerator generates a Markdown-formatted resume via TemplateGenerator.
###
MarkdownGenerator = module.exports = TemplateGenerator.extend
init: () -> @_super 'md', 'txt'

View File

@ -0,0 +1,204 @@
###*
Definition of the TemplateGenerator class. TODO: Refactor
@license MIT. See LICENSE.md for details.
@module template-generator.js
###
FS = require 'fs-extra'
_ = require 'underscore'
MD = require 'marked'
XML = require 'xml-escape'
PATH = require 'path'
parsePath = require 'parse-filepath'
MKDIRP = require 'mkdirp'
BaseGenerator = require './base-generator'
EXTEND = require 'extend'
FRESHTheme = require '../core/fresh-theme'
JRSTheme = require '../core/jrs-theme'
###*
TemplateGenerator performs resume generation via local Handlebar or Underscore
style template expansion and is appropriate for text-based formats like HTML,
plain text, and XML versions of Microsoft Word, Excel, and OpenOffice.
@class TemplateGenerator
###
TemplateGenerator = module.exports = BaseGenerator.extend
###* Constructor. Set the output format and template format for this
generator. Will usually be called by a derived generator such as
HTMLGenerator or MarkdownGenerator. ###
init: ( outputFormat, templateFormat, cssFile ) ->
@_super outputFormat
@tplFormat = templateFormat || outputFormat
return
###* Generate a resume using string-based inputs and outputs without touching
the filesystem.
@method invoke
@param rez A FreshResume object.
@param opts Generator options.
@returns {Array} An array of objects representing the generated output
files. ###
invoke: ( rez, opts ) ->
opts =
if opts
then (this.opts = EXTEND( true, { }, _defaultOpts, opts ))
else this.opts
# Sort such that CSS files are processed before others
curFmt = opts.themeObj.getFormat( this.format )
curFmt.files = _.sortBy curFmt.files, (fi) -> fi.ext != 'css'
# Run the transformation!
results = curFmt.files.map( ( tplInfo, idx ) ->
trx = @.single rez, tplInfo.data, this.format, opts, opts.themeObj, curFmt
if tplInfo.ext == 'css'
curFmt.files[idx].data = trx
else tplInfo.ext == 'html'
#tplInfo.css contains the CSS data loaded by theme
#tplInfo.cssPath contains the absolute path to the source CSS File
return info: tplInfo, data: trx
, @)
files: results
###* Generate a resume using file-based inputs and outputs. Requires access
to the local filesystem.
@method generate
@param rez A FreshResume object.
@param f Full path to the output resume file to generate.
@param opts Generator options. ###
generate: ( rez, f, opts ) ->
# Prepare
this.opts = EXTEND( true, { }, _defaultOpts, opts );
# Call the string-based generation method to perform the generation.
genInfo = this.invoke( rez, null )
outFolder = parsePath( f ).dirname
curFmt = opts.themeObj.getFormat( this.format )
# Process individual files within this format. For example, the HTML
# output format for a theme may have multiple HTML files, CSS files,
# etc. Process them here.
genInfo.files.forEach(( file ) ->
# Pre-processing
file.info.orgPath = file.info.orgPath || '' # <-- For JRS themes
thisFilePath = PATH.join( outFolder, file.info.orgPath )
if this.onBeforeSave
file.data = this.onBeforeSave
theme: opts.themeObj
outputFile: if file.info.major then f else thisFilePath
mk: file.data
opts: this.opts
if !file.data
return # PDF etc
# Write the file
fileName = if file.info.major then f else thisFilePath
MKDIRP.sync PATH.dirname( fileName )
FS.writeFileSync fileName, file.data, { encoding: 'utf8', flags: 'w' }
# Post-processing
if @onAfterSave
@onAfterSave( outputFile: fileName, mk: file.data, opts: this.opts )
, @)
# Some themes require a symlink structure. If so, create it.
if curFmt.symLinks
Object.keys( curFmt.symLinks ).forEach (loc) ->
absLoc = PATH.join outFolder, loc
absTarg = PATH.join PATH.dirname(absLoc), curFmt.symLinks[loc]
# 'file', 'dir', or 'junction' (Windows only)
type = parsePath( absLoc ).extname ? 'file' : 'junction'
FS.symlinkSync absTarg, absLoc, type
genInfo
###* Perform a single resume resume transformation using string-based inputs
and outputs without touching the local file system.
@param json A FRESH or JRS resume object.
@param jst The stringified template data
@param format The format name, such as "html" or "latex"
@param cssInfo Needs to be refactored.
@param opts Options and passthrough data. ###
single: ( json, jst, format, opts, theme, curFmt ) ->
if this.opts.freezeBreaks
jst = freeze jst
eng = require '../renderers/' + theme.engine + '-generator'
result = eng.generate json, jst, format, curFmt, opts, theme
if this.opts.freezeBreaks
result = unfreeze result
result
###* Export the TemplateGenerator function/ctor. ###
module.exports = TemplateGenerator
###* Freeze newlines for protection against errant JST parsers. ###
freeze = ( markup ) ->
markup.replace( _reg.regN, _defaultOpts.nSym )
markup.replace( _reg.regR, _defaultOpts.rSym )
###* Unfreeze newlines when the coast is clear. ###
unfreeze = ( markup ) ->
markup.replace _reg.regSymR, '\r'
markup.replace _reg.regSymN, '\n'
###* Default template generator options. ###
_defaultOpts =
engine: 'underscore'
keepBreaks: true
freezeBreaks: false
nSym: '&newl;' # newline entity
rSym: '&retn;' # return entity
template:
interpolate: /\{\{(.+?)\}\}/g
escape: /\{\{\=(.+?)\}\}/g
evaluate: /\{\%(.+?)\%\}/g
comment: /\{\#(.+?)\#\}/g
filters:
out: ( txt ) -> txt
raw: ( txt ) -> txt
xml: ( txt ) -> XML(txt)
md: ( txt ) -> MD( txt || '' )
mdin: ( txt ) -> MD(txt || '' ).replace(/^\s*<p>|<\/p>\s*$/gi, '')
lower: ( txt ) -> txt.toLowerCase()
link: ( name, url ) ->
return if url then '<a href="' + url + '">' + name + '</a>' else name
prettify: # ← See https://github.com/beautify-web/js-beautify#options
indent_size: 2
unformatted: ['em','strong','a']
max_char: 80 # ← See lib/html.js in above-linked repo
#wrap_line_length: 120, <-- Don't use this
###* Regexes for linebreak preservation. ###
_reg =
regN: new RegExp( '\n', 'g' )
regR: new RegExp( '\r', 'g' )
regSymN: new RegExp( _defaultOpts.nSym, 'g' )
regSymR: new RegExp( _defaultOpts.rSym, 'g' )

View File

@ -0,0 +1,14 @@
###*
Definition of the TextGenerator class.
@license MIT. See LICENSE.md for details.
@module text-generator.js
###
TemplateGenerator = require './template-generator'
###*
The TextGenerator generates a plain-text resume via the TemplateGenerator.
###
TextGenerator = module.exports = TemplateGenerator.extend
init: () -> @_super 'txt'

View File

@ -0,0 +1,11 @@
###
Definition of the WordGenerator class.
@license MIT. See LICENSE.md for details.
@module generators/word-generator
###
TemplateGenerator = require './template-generator'
WordGenerator = module.exports = TemplateGenerator.extend
init: () -> @_super 'doc', 'xml'

View File

@ -0,0 +1,13 @@
###*
Definition of the XMLGenerator class.
@license MIT. See LICENSE.md for details.
@module generatprs/xml-generator
###
BaseGenerator = require './base-generator'
###*
The XmlGenerator generates an XML resume via the TemplateGenerator.
###
XMLGenerator = module.exports = BaseGenerator.extend
init: () -> @_super 'xml'

View File

@ -0,0 +1,15 @@
###*
Definition of the YAMLGenerator class.
@module yaml-generator.js
@license MIT. See LICENSE.md for details.
###
TemplateGenerator = require './template-generator'
###*
YamlGenerator generates a YAML-formatted resume via TemplateGenerator.
###
YAMLGenerator = module.exports = TemplateGenerator.extend
init: () -> @_super 'yml', 'yml'

View File

@ -0,0 +1,51 @@
###*
Generic template helper definitions for command-line output.
@module console-helpers.js
@license MIT. See LICENSE.md for details.
###
PAD = require 'string-padding'
LO = require 'lodash'
CHALK = require 'chalk'
_ = require 'underscore'
require '../utils/string'
consoleFormatHelpers = module.exports =
v: ( val, defaultVal, padding, style ) ->
retVal = if ( val is null || val is undefined ) then defaultVal else val
spaces = 0
if String.is padding
spaces = parseInt padding, 10
spaces = 0 if isNaN spaces
else if _.isNumber padding
spaces = padding
if spaces != 0
retVal = PAD retVal, Math.abs(spaces), null, if spaces > 0 then PAD.LEFT else PAD.RIGHT
if style && String.is( style )
retVal = LO.get( CHALK, style )( retVal )
retVal
gapLength: (val) ->
if val < 35
return CHALK.green.bold val
else if val < 95
return CHALK.yellow.bold val
else
return CHALK.red.bold val
style: ( val, style ) ->
LO.get( CHALK, style )( val )
isPlural: ( val, options ) ->
if val > 1
return options.fn(this)
pad: ( val, spaces ) ->
PAD val, Math.abs(spaces), null, if spaces > 0 then PAD.LEFT else PAD.RIGHT

View File

@ -0,0 +1,524 @@
###*
Generic template helper definitions for HackMyResume / FluentCV.
@license MIT. See LICENSE.md for details.
@module helpers/generic-helpers
###
MD = require 'marked'
H2W = require '../utils/html-to-wpml'
XML = require 'xml-escape'
FluentDate = require '../core/fluent-date'
HMSTATUS = require '../core/status-codes'
moment = require 'moment'
FS = require 'fs'
LO = require 'lodash'
PATH = require 'path'
printf = require 'printf'
_ = require 'underscore'
unused = require '../utils/string';
###* Generic template helper function definitions. ###
GenericHelpers = module.exports =
###*
Convert the input date to a specified format through Moment.js.
If date is invalid, will return the time provided by the user,
or default to the fallback param or 'Present' if that is set to true
@method formatDate
###
formatDate: (datetime, format, fallback) ->
if moment
momentDate = moment datetime
return momentDate.format(format) if momentDate.isValid()
datetime || (typeof fallback == 'string' ? fallback : (fallback == true ? 'Present' : null));
###*
Given a resume sub-object with a start/end date, format a representation of
the date range.
@method dateRange
###
dateRange: ( obj, fmt, sep, fallback, options ) ->
return '' if !obj
_fromTo obj.start, obj.end, fmt, sep, fallback, options
###*
Format a from/to date range for display.
@method toFrom
###
fromTo: () -> _fromTo.apply this, arguments
###*
Return a named color value as an RRGGBB string.
@method toFrom
###
color: ( colorName, colorDefault ) ->
# Key must be specified
if !(colorName and colorName.trim())
_reportError HMSTATUS.invalidHelperUse,
helper: 'fontList', error: HMSTATUS.missingParam, expected: 'name'
else
return colorDefault if !GenericHelpers.theme.colors
ret = GenericHelpers.theme.colors[ colorName ]
if !(ret && ret.trim())
return colorDefault
ret
###*
Return true if the section is present on the resume and has at least one
element.
@method section
###
section: ( title, options ) ->
title = title.trim().toLowerCase()
obj = LO.get this.r, title
if _.isArray obj
return if obj.length then options.fn(this) else undefined;
else if _.isObject obj
return (obj.history && obj.history.length) ||
if ( obj.sets && obj.sets.length )
then options.fn(this) else undefined
###*
Emit the size of the specified named font.
@param key {String} A named style from the "fonts" section of the theme's
theme.json file. For example: 'default' or 'heading1'.
###
fontSize: ( key, defSize, units ) ->
ret = ''
hasDef = defSize && ( String.is( defSize ) || _.isNumber( defSize ))
# Key must be specified
if !(key && key.trim())
_reportError( HMSTATUS.invalidHelperUse, {
helper: 'fontSize', error: HMSTATUS.missingParam, expected: 'key'
})
return ret
else if GenericHelpers.theme.fonts
fontSpec = LO.get( GenericHelpers.theme.fonts, this.format + '.' + key )
if !fontSpec
# Check for an "all" format
if GenericHelpers.theme.fonts.all
fontSpec = GenericHelpers.theme.fonts.all[ key ]
if( fontSpec )
# fontSpec can be a string, an array, or an object
if( String.is( fontSpec ))
# No font size was specified, only a font family.
else if( _.isArray( fontSpec ))
# An array of fonts were specified. Each one could be a string
# or an object
if( !String.is( fontSpec[0] ))
ret = fontSpec[0].size
else
# A font description object.
ret = fontSpec.size
# We weren't able to lookup the specified key. Default to defFont.
if !ret
if hasDef
ret = defSize
else
_reportError( HMSTATUS.invalidHelperUse, {
helper: 'fontSize', error: HMSTATUS.missingParam,
expected: 'defSize'})
ret = ''
ret
###*
Emit the font face (such as 'Helvetica' or 'Calibri') associated with the
provided key.
@param key {String} A named style from the "fonts" section of the theme's
theme.json file. For example: 'default' or 'heading1'.
@param defFont {String} The font to use if the specified key isn't present.
Can be any valid font-face name such as 'Helvetica Neue' or 'Calibri'.
###
fontFace: ( key, defFont ) ->
ret = ''
hasDef = defFont && String.is( defFont )
# Key must be specified
if !( key && key.trim())
_reportError( HMSTATUS.invalidHelperUse, {
helper: 'fontFace', error: HMSTATUS.missingParam, expected: 'key'
})
return ret
# If the theme has a "fonts" section, lookup the font face.
else if( GenericHelpers.theme.fonts )
fontSpec = LO.get( GenericHelpers.theme.fonts, this.format + '.' + key)
if !fontSpec
# Check for an "all" format
if GenericHelpers.theme.fonts.all
fontSpec = GenericHelpers.theme.fonts.all[ key ]
if fontSpec
# fontSpec can be a string, an array, or an object
if String.is fontSpec
ret = fontSpec
else if _.isArray fontSpec
# An array of fonts were specified. Each one could be a string
# or an object
ret = if String.is( fontSpec[0] ) then fontSpec[0] else fontSpec[0].name
else
# A font description object.
ret = fontSpec.name;
# We weren't able to lookup the specified key. Default to defFont.
if !(ret && ret.trim())
ret = defFont
if !hasDef
_reportError( HMSTATUS.invalidHelperUse, {
helper: 'fontFace', error: HMSTATUS.missingParam,
expected: 'defFont'});
ret = '';
return ret;
###*
Emit a comma-delimited list of font names suitable associated with the
provided key.
@param key {String} A named style from the "fonts" section of the theme's
theme.json file. For example: 'default' or 'heading1'.
@param defFontList {Array} The font list to use if the specified key isn't
present. Can be an array of valid font-face name such as 'Helvetica Neue'
or 'Calibri'.
@param sep {String} The default separator to use in the rendered output.
Defaults to ", " (comma with a space).
###
fontList: ( key, defFontList, sep ) ->
ret = ''
hasDef = defFontList && String.is( defFontList )
# Key must be specified
if !( key && key.trim())
_reportError( HMSTATUS.invalidHelperUse, {
helper: 'fontList', error: HMSTATUS.missingParam, expected: 'key'
});
# If the theme has a "fonts" section, lookup the font list.
else if GenericHelpers.theme.fonts
fontSpec = LO.get GenericHelpers.theme.fonts, this.format + '.' + key
if !fontSpec
if GenericHelpers.theme.fonts.all
fontSpec = GenericHelpers.theme.fonts.all[ key ]
if fontSpec
# fontSpec can be a string, an array, or an object
if String.is fontSpec
ret = fontSpec
else if _.isArray fontSpec
# An array of fonts were specified. Each one could be a string
# or an object
fontSpec = fontSpec.map ( ff ) ->
"'" + (if String.is( ff ) then ff else ff.name) + "'"
ret = fontSpec.join( if sep == undefined then ', ' else (sep || '') )
else
# A font description object.
ret = fontSpec.name
# The key wasn't found in the "fonts" section. Default to defFont.
if !(ret && ret.trim())
if !hasDef
_reportError( HMSTATUS.invalidHelperUse, {
helper: 'fontList', error: HMSTATUS.missingParam,
expected: 'defFontList'})
ret = ''
else
ret = defFontList
return ret;
###*
Capitalize the first letter of the word.
@method section
###
camelCase: (val) ->
val = (val && val.trim()) || ''
return if val then (val.charAt(0).toUpperCase() + val.slice(1)) else val
###*
Return true if the context has the property or subpropery.
@method has
###
has: ( title, options ) ->
title = title && title.trim().toLowerCase()
if LO.get this.r, title
return options.fn this
return
###*
Generic template helper function to display a user-overridable section
title for a FRESH resume theme. Use this in lieue of hard-coding section
titles.
Usage:
{{sectionTitle "sectionName"}}
{{sectionTitle "sectionName" "sectionTitle"}}
Example:
{{sectionTitle "Education"}}
{{sectionTitle "Employment" "Project History"}}
@param sect_name The name of the section being title. Must be one of the
top-level FRESH resume sections ("info", "education", "employment", etc.).
@param sect_title The theme-specified section title. May be replaced by the
user.
@method sectionTitle
###
sectionTitle: ( sname, stitle ) ->
# If not provided by the user, stitle should default to sname. ps.
# Handlebars silently passes in the options object to the last param,
# where in Underscore stitle will be null/undefined, so we check both.
stitle = (stitle && String.is(stitle) && stitle) || sname
# If there's a section title override, use it.
( this.opts.stitles &&
this.opts.stitles[ sname.toLowerCase().trim() ] ) ||
stitle;
###*
Convert inline Markdown to inline WordProcessingML.
@method wpml
###
wpml: ( txt, inline ) ->
return '' if !txt
inline = (inline && !inline.hash) || false
txt = XML(txt.trim())
txt = if inline then MD(txt).replace(/^\s*<p>|<\/p>\s*$/gi, '') else MD(txt)
txt = H2W( txt )
return txt
###*
Emit a conditional link.
@method link
###
link: ( text, url ) ->
return if url && url.trim() then ('<a href="' + url + '">' + text + '</a>') else text
###*
Return the last word of the specified text.
@method lastWord
###
lastWord: ( txt ) ->
return if txt && txt.trim() then _.last( txt.split(' ') ) else ''
###*
Convert a skill level to an RGB color triplet. TODO: refactor
@method skillColor
@param lvl Input skill level. Skill level can be expressed as a string
("beginner", "intermediate", etc.), as an integer (1,5,etc), as a string
integer ("1", "5", etc.), or as an RRGGBB color triplet ('#C00000',
'#FFFFAA').
###
skillColor: ( lvl ) ->
idx = skillLevelToIndex lvl
skillColors = (this.theme && this.theme.palette &&
this.theme.palette.skillLevels) ||
[ '#FFFFFF', '#5CB85C', '#F1C40F', '#428BCA', '#C00000' ]
return skillColors[idx]
###*
Return an appropriate height. TODO: refactor
@method lastWord
###
skillHeight: ( lvl ) ->
idx = skillLevelToIndex lvl
['38.25', '30', '16', '8', '0'][idx]
###*
Return all but the last word of the input text.
@method initialWords
###
initialWords: ( txt ) ->
if txt && txt.trim() then _.initial( txt.split(' ') ).join(' ') else ''
###*
Trim the protocol (http or https) from a URL/
@method trimURL
###
trimURL: ( url ) ->
if url && url.trim() then url.trim().replace(/^https?:\/\//i, '') else ''
###*
Convert text to lowercase.
@method toLower
###
toLower: ( txt ) ->
if txt && txt.trim() then txt.toLowerCase() else ''
###*
Convert text to lowercase.
@method toLower
###
toUpper: ( txt ) ->
if txt && txt.trim() then txt.toUpperCase() else ''
###*
Return true if either value is truthy.
@method either
###
either: ( lhs, rhs, options ) ->
if lhs || rhs
return options.fn this
###*
Conditional stylesheet link. Creates a link to the specified stylesheet with
<link> or embeds the styles inline with <style></style>, depending on the
theme author's and user's preferences.
@param url {String} The path to the CSS file.
@param linkage {String} The default link method. Can be either `embed` or
`link`. If omitted, defaults to `embed`. Can be overridden by the `--css`
command-line switch.
###
styleSheet: ( url, linkage ) ->
# Establish the linkage style
linkage = this.opts.css || linkage || 'embed';
# Create the <link> or <style> tag
ret = ''
if linkage == 'link'
ret = printf('<link href="%s" rel="stylesheet" type="text/css">', url)
else
rawCss = FS.readFileSync(
PATH.join( this.opts.themeObj.folder, '/src/', url ), 'utf8' )
renderedCss = this.engine.generateSimple( this, rawCss )
ret = printf('<style>%s</style>', renderedCss )
# If the currently-executing template is inherited, append styles
if this.opts.themeObj.inherits && this.opts.themeObj.inherits.html && this.format == 'html'
ret +=
if (linkage == 'link')
then '<link href="' + this.opts.themeObj.overrides.path + '" rel="stylesheet" type="text/css">'
else '<style>' + this.opts.themeObj.overrides.data + '</style>'
# TODO: It would be nice to use Handlebar.SafeString here, but these
# are supposed to be generic helpers. Provide an equivalent, or expose
# it when Handlebars is the chosen engine, which is most of the time.
ret
###*
Perform a generic comparison.
See: http://doginthehat.com.au/2012/02/comparison-block-helper-for-handlebars-templates
@method compare
###
compare: (lvalue, rvalue, options) ->
if arguments.length < 3
throw new Error("Handlerbars Helper 'compare' needs 2 parameters")
operator = options.hash.operator || "=="
operators =
'==': (l,r) -> l == r
'===': (l,r) -> l == r
'!=': (l,r) -> l != r
'<': (l,r) -> l < r
'>': (l,r) -> l > r
'<=': (l,r) -> l <= r
'>=': (l,r) -> l >= r
'typeof': (l,r) -> typeof l == r
if !operators[operator]
throw new Error("Handlerbars Helper 'compare' doesn't know the operator "+operator)
result = operators[operator]( lvalue, rvalue )
return if result then options.fn(this) else options.inverse(this)
###*
Report an error to the outside world without throwing an exception. Currently
relies on kludging the running verb into. opts.
###
_reportError = ( code, params ) ->
GenericHelpers.opts.errHandler.err( code, params )
###*
Format a from/to date range for display.
###
_fromTo = ( dateA, dateB, fmt, sep, fallback ) ->
# Prevent accidental use of safe.start, safe.end, safe.date
# The dateRange helper is for raw dates only
if moment.isMoment( dateA ) || moment.isMoment( dateB )
_reportError( HMSTATUS.invalidHelperUse, { helper: 'dateRange' } )
return ''
dateFrom = null
dateTo = null
dateTemp = null
# Check for 'current', 'present', 'now', '', null, and undefined
dateA = dateA || ''
dateB = dateB || ''
dateATrim = dateA.trim().toLowerCase()
dateBTrim = dateB.trim().toLowerCase()
reserved = ['current','present','now', '']
fmt = (fmt && String.is(fmt) && fmt) || 'YYYY-MM'
sep = (sep && String.is(sep) && sep) || ''
if _.contains( reserved, dateATrim )
dateFrom = fallback || '???'
else
dateTemp = FluentDate.fmt( dateA )
dateFrom = dateTemp.format( fmt )
if _.contains( reserved, dateBTrim )
dateTo = fallback || 'Current'
else
dateTemp = FluentDate.fmt( dateB )
dateTo = dateTemp.format( fmt )
if dateFrom && dateTo
return dateFrom + sep + dateTo
else if dateFrom || dateTo
return dateFrom || dateTo
return ''
skillLevelToIndex = ( lvl ) ->
idx = 0
if String.is( lvl )
lvl = lvl.trim().toLowerCase()
intVal = parseInt( lvl )
if isNaN intVal
switch lvl
when 'beginner' then idx = 1
when 'intermediate' then idx = 2
when 'advanced' then idx = 3
when 'master' then idx = 4
else
idx = Math.min( intVal / 2, 4 )
idx = Math.max( 0, idx )
else
idx = Math.min( lvl / 2, 4 )
idx = Math.max( 0, idx )
idx
# Note [1] --------------------------------------------------------------------
# Make sure it's precisely a string or array since some template engines jam
# their options/context object into the last parameter and we are allowing the
# defFont parameter to be omitted in certain cases. This is a little kludgy,
# but works fine for this case. If we start doing this regularly, we should
# rebind these parameters.
# Note [2]: -------------------------------------------------------------------
# If execution reaches here, some sort of cosmic ray or sunspot has landed on
# HackMyResume, or a theme author is deliberately messing with us by doing
# something like:
#
# "fonts": {
# "default": "",
# "heading1": null
# }
#
# Rather than sort it out, we'll just fall back to defFont.

View File

@ -0,0 +1,20 @@
###*
Template helper definitions for Handlebars.
@license MIT. Copyright (c) 2015 James Devlin / FluentDesk.
@module handlebars-helpers.js
###
HANDLEBARS = require 'handlebars'
_ = require 'underscore'
helpers = require './generic-helpers'
###*
Register useful Handlebars helpers.
@method registerHelpers
###
module.exports = ( theme, opts ) ->
helpers.theme = theme
helpers.opts = opts
HANDLEBARS.registerHelper helpers

View File

@ -0,0 +1,24 @@
###*
Template helper definitions for Underscore.
@license MIT. Copyright (c) 2016 hacksalot (https://github.com/hacksalot)
@module handlebars-helpers.js
###
HANDLEBARS = require('handlebars')
_ = require('underscore')
helpers = require('./generic-helpers')
###*
Register useful Underscore helpers.
@method registerHelpers
###
module.exports = ( theme, opts, cssInfo, ctx, eng ) ->
helpers.theme = theme
helpers.opts = opts
helpers.cssInfo = cssInfo
helpers.engine = eng
ctx.h = helpers
_.each helpers, ( hVal, hKey ) ->
if _.isFunction hVal
_.bind hVal, ctx
, @

Submodule src/hmc deleted from a6f5f9e570

44
src/index.coffee Normal file
View File

@ -0,0 +1,44 @@
###*
External API surface for HackMyResume.
@license MIT. See LICENSE.md for details.
@module hackmycore/index
###
###*
API facade for HackMyCore.
###
HackMyCore = module.exports =
verbs:
build: require './verbs/build'
analyze: require './verbs/analyze'
validate: require './verbs/validate'
convert: require './verbs/convert'
new: require './verbs/create'
peek: require './verbs/peek'
alias:
generate: require './verbs/build'
create: require './verbs/create'
options: require './core/default-options'
formats: require './core/default-formats'
Sheet: require './core/fresh-resume'
FRESHResume: require './core/fresh-resume'
JRSResume: require './core/jrs-resume'
FRESHTheme: require './core/fresh-theme'
JRSTheme: require './core/jrs-theme'
FluentDate: require './core/fluent-date'
HtmlGenerator: require './generators/html-generator'
TextGenerator: require './generators/text-generator'
HtmlPdfCliGenerator: require './generators/html-pdf-cli-generator'
WordGenerator: require './generators/word-generator'
MarkdownGenerator: require './generators/markdown-generator'
JsonGenerator: require './generators/json-generator'
YamlGenerator: require './generators/yaml-generator'
JsonYamlGenerator: require './generators/json-yaml-generator'
LaTeXGenerator: require './generators/latex-generator'
HtmlPngGenerator: require './generators/html-png-generator'

View File

@ -0,0 +1,139 @@
###*
Employment gap analysis for HackMyResume.
@license MIT. See LICENSE.md for details.
@module inspectors/gap-inspector
###
_ = require 'underscore'
FluentDate = require '../core/fluent-date'
moment = require 'moment'
LO = require 'lodash'
###*
Identify gaps in the candidate's employment history.
###
gapInspector = module.exports =
moniker: 'gap-inspector'
###*
Run the Gap Analyzer on a resume.
@method run
@return {Array} An array of object representing gaps in the candidate's
employment history. Each object provides the start, end, and duration of the
gap:
{ <-- gap
start: // A Moment.js date
end: // A Moment.js date
duration: // Gap length
}
###
run: (rez) ->
# This is what we'll return
coverage =
gaps: []
overlaps: []
pct: '0%'
duration:
total: 0
work: 0
gaps: 0
# Missing employment section? Bye bye.
hist = LO.get rez, 'employment.history'
return coverage if !hist || !hist.length
# Convert the candidate's employment history to an array of dates,
# where each element in the array is a start date or an end date of a
# job -- it doesn't matter which.
new_e = hist.map( ( job ) ->
obj = _.pick( job, ['start', 'end'] )
if obj && (obj.start || obj.end)
obj = _.pairs( obj )
obj[0][1] = FluentDate.fmt( obj[0][1] )
if obj.length > 1
obj[1][1] = FluentDate.fmt( obj[1][1] )
return obj
)
# Flatten the array, remove empties, and sort
new_e = _.filter _.flatten( new_e, true ), (v) ->
return v && v.length && v[0] && v[0].length
return coverage if !new_e || !new_e.length
new_e = _.sortBy new_e, ( elem ) -> return elem[1].unix()
# Iterate over elements in the array. Each time a start date is found,
# increment a reference count. Each time an end date is found, decrement
# the reference count. When the reference count reaches 0, we have a gap.
# When the reference count is > 0, the candidate is employed. When the
# reference count reaches 2, the candidate is overlapped.
num_gaps = 0
ref_count = 0
total_gap_days = 0
gap_start = null
new_e.forEach (point) ->
inc = if point[0] == 'start' then 1 else -1
ref_count += inc
# If the ref count just reached 0, start a new GAP
if ref_count == 0
coverage.gaps.push( { start: point[1], end: null })
# If the ref count reached 1 by rising, end the last GAP
else if ref_count == 1 && inc == 1
lastGap = _.last( coverage.gaps )
if lastGap
lastGap.end = point[1]
lastGap.duration = lastGap.end.diff( lastGap.start, 'days' )
total_gap_days += lastGap.duration
# If the ref count reaches 2 by rising, start a new OVERLAP
else if ref_count == 2 && inc == 1
coverage.overlaps.push( { start: point[1], end: null })
# If the ref count reaches 1 by falling, end the last OVERLAP
else if ref_count == 1 && inc == -1
lastOver = _.last( coverage.overlaps )
if lastOver
lastOver.end = point[1]
lastOver.duration = lastOver.end.diff( lastOver.start, 'days' )
if lastOver.duration == 0
coverage.overlaps.pop()
# It's possible that the last gap/overlap didn't have an explicit .end
# date.If so, set the end date to the present date and compute the
# duration normally.
if coverage.overlaps.length
o = _.last( coverage.overlaps )
if o && !o.end
o.end = moment()
o.duration = o.end.diff( o.start, 'days' )
if coverage.gaps.length
g = _.last( coverage.gaps )
if g && !g.end
g.end = moment()
g.duration = g.end.diff( g.start, 'days' )
# Package data for return to the client
tdur = rez.duration('days')
dur =
total: tdur
work: tdur - total_gap_days
gaps: total_gap_days
coverage.pct = if dur.total > 0 && dur.work > 0 then ((((dur.total - dur.gaps) / dur.total) * 100)).toFixed(1) + '%' else '???'
coverage.duration = dur
coverage

View File

@ -0,0 +1,68 @@
###*
Keyword analysis for HackMyResume.
@license MIT. See LICENSE.md for details.
@module inspectors/keyword-inspector
###
_ = require('underscore')
FluentDate = require('../core/fluent-date')
###*
Analyze the resume's use of keywords.
TODO: BUG: Keyword search regex is inaccurate, especially for one or two
letter keywords like "C" or "CLI".
@class keywordInspector
###
keywordInspector = module.exports =
###* A unique name for this inspector. ###
moniker: 'keyword-inspector'
###*
Run the Keyword Inspector on a resume.
@method run
@return An collection of statistical keyword data.
###
run: ( rez ) ->
# "Quote" or safely escape a keyword so it can be used as a regex. For
# example, if the keyword is "C++", yield "C\+\+".
# http://stackoverflow.com/a/2593661/4942583
regex_quote = (str) -> (str + '').replace(/[.?*+^$[\]\\(){}|-]/ig, "\\$&")
# Create a searchable plain-text digest of the resume
# TODO: BUG: Don't search within keywords for other keywords. Job A
# declares the "foo" keyword. Job B declares the "foo & bar" keyword. Job
# B's mention of "foobar" should not count as a mention of "foo".
# To achieve this, remove keywords from the search digest and treat them
# separately.
searchable = ''
rez.transformStrings ['imp', 'computed', 'safe'], ( key, val ) ->
searchable += ' ' + val
# Assemble a regex skeleton we can use to test for keywords with a bit
# more
prefix = '(?:' + ['^', '\\s+', '[\\.,]+'].join('|') + ')'
suffix = '(?:' + ['$', '\\s+', '[\\.,]+'].join('|') + ')'
return rez.keywords().map (kw) ->
# 1. Using word boundary or other regex class is inaccurate
#
# var regex = new RegExp( '\\b' + regex_quote( kw )/* + '\\b'*/, 'ig');
#
# 2. Searching for the raw keyword is inaccurate ("C" will match any
# word containing a 'c'!).
#
# var regex = new RegExp( regex_quote( kw ), 'ig');
#
# 3. Instead, use a custom regex with special delimeters.
regex_str = prefix + regex_quote( kw ) + suffix
regex = new RegExp( regex_str, 'ig')
myArray = null
count = 0
while (myArray = regex.exec( searchable )) != null
count++
name: kw
count: count

View File

@ -0,0 +1,36 @@
###*
Section analysis for HackMyResume.
@license MIT. See LICENSE.md for details.
@module inspectors/totals-inspector
###
_ = require 'underscore'
FluentDate = require '../core/fluent-date'
###*
Retrieve sectional overview and summary information.
@class totalsInspector
###
totalsInspector = module.exports =
moniker: 'totals-inspector'
###*
Run the Totals Inspector on a resume.
@method run
@return An object containing summary information for each section on the
resume.
###
run: ( rez ) ->
sectionTotals = { }
_.each rez, (val, key) ->
if _.isArray( val ) && !_.isString(val)
sectionTotals[ key ] = val.length
else if val.history && _.isArray( val.history )
sectionTotals[ key ] = val.history.length;
else if val.sets && _.isArray( val.sets )
sectionTotals[ key ] = val.sets.length;
totals: sectionTotals,
numSections: Object.keys( sectionTotals ).length

View File

@ -0,0 +1,95 @@
###*
Definition of the HandlebarsGenerator class.
@license MIT. See LICENSE.md for details.
@module renderers/handlebars-generator
###
_ = require 'underscore'
HANDLEBARS = require 'handlebars'
FS = require 'fs'
registerHelpers = require '../helpers/handlebars-helpers'
PATH = require 'path'
parsePath = require 'parse-filepath'
READFILES = require 'recursive-readdir-sync'
HMSTATUS = require '../core/status-codes'
SLASH = require 'slash'
###*
Perform template-based resume generation using Handlebars.js.
@class HandlebarsGenerator
###
HandlebarsGenerator = module.exports =
generateSimple: ( data, tpl ) ->
try
# Compile and run the Handlebars template.
template = HANDLEBARS.compile tpl, {strict: false, assumeObjects: false}
return template data
catch
throw
fluenterror: if template then HMSTATUS.invokeTemplate else HMSTATUS.compileTemplate
inner: _error
generate: ( json, jst, format, curFmt, opts, theme ) ->
# Set up partials and helpers
registerPartials format, theme
registerHelpers theme, opts
# Preprocess text
encData = json
if format == 'html' || format == 'pdf'
encData = json.markdownify()
if( format == 'doc' )
encData = json.xmlify()
# Set up the context
ctx =
r: encData
RAW: json
filt: opts.filters
format: format
opts: opts
engine: @
results: curFmt.files
headFragment: opts.headFragment || ''
# Render the template
return this.generateSimple ctx, jst
registerPartials = (format, theme) ->
if _.contains( ['html','doc','md','txt','pdf'], format )
# Locate the global partials folder
partialsFolder = PATH.join(
parsePath( require.resolve('fresh-themes') ).dirname,
'/partials/',
if format == 'pdf' then 'html' else format
)
# Register global partials in the /partials/[format] folder
# TODO: Only do this once per HMR invocation.
_.each READFILES( partialsFolder, (error)->{ }), ( el ) ->
pathInfo = parsePath el
name = SLASH PATH.relative( partialsFolder, el ).replace(/\.(?:html|xml|hbs|md|txt)$/i, '')
tplData = FS.readFileSync el, 'utf8'
compiledTemplate = HANDLEBARS.compile tplData
HANDLEBARS.registerPartial name, compiledTemplate
theme.partialsInitialized = true
# Register theme-specific partials
_.each theme.partials, ( el ) ->
tplData = FS.readFileSync el.path, 'utf8'
compiledTemplate = HANDLEBARS.compile tplData
HANDLEBARS.registerPartial el.name, compiledTemplate

View File

@ -0,0 +1,44 @@
###*
Definition of the JRSGenerator class.
@license MIT. See LICENSE.md for details.
@module renderers/jrs-generator
###
_ = require('underscore')
HANDLEBARS = require('handlebars')
FS = require('fs')
registerHelpers = require('../helpers/handlebars-helpers')
PATH = require('path')
parsePath = require('parse-filepath')
READFILES = require('recursive-readdir-sync')
SLASH = require('slash')
MD = require('marked')
###*
Perform template-based resume generation for JSON Resume themes.
@class JRSGenerator
###
JRSGenerator = module.exports =
generate: ( json, jst, format, cssInfo, opts, theme ) ->
# Disable JRS theme chatter (console.log, console.error, etc.)
turnoff = ['log', 'error', 'dir'];
org = turnoff.map(c) ->
ret = console[c]
console[c] = () ->
# Freeze and render
rezHtml = theme.render json.harden()
# Turn logging back on
turnoff.forEach (c, idx) -> console[c] = org[idx]
# Unfreeze and apply Markdown
rezHtml = rezHtml.replace /@@@@~.*?~@@@@/gm, (val) ->
MDIN( val.replace( /~@@@@/gm,'' ).replace( /@@@@~/gm,'' ) )
MDIN = (txt) -> # TODO: Move this
MD(txt || '' ).replace(/^\s*<p>|<\/p>\s*$/gi, '')

View File

@ -0,0 +1,52 @@
###*
Definition of the UnderscoreGenerator class.
@license MIT. See LICENSE.md for details.
@module underscore-generator.js
###
_ = require 'underscore'
registerHelpers = require '../helpers/underscore-helpers'
HMSTATUS = require '../core/status-codes'
###*
Perform template-based resume generation using Underscore.js.
@class UnderscoreGenerator
###
UnderscoreGenerator = module.exports =
generateSimple: ( data, tpl ) ->
try
# Compile and run the Handlebars template.
template = _.template( tpl );
return template( data );
catch
throw
fluenterror: if template then HMSTATUS.invokeTemplate else HMSTATUS.compileTemplate,
inner: _error
generate: ( json, jst, format, cssInfo, opts, theme ) ->
# Tweak underscore's default template delimeters
delims = (opts.themeObj && opts.themeObj.delimeters) || opts.template;
if opts.themeObj && opts.themeObj.delimeters
delims = _.mapObject delims, (val,key) -> new RegExp( val, "ig")
_.templateSettings = delims;
# Strip {# comments #}
jst = jst.replace delims.comment, ''
ctx =
r: if format == 'html' || format == 'pdf' || format == 'png' then json.markdownify() else json
filt: opts.filters
XML: require 'xml-escape'
RAW: json
cssInfo: cssInfo
#engine: this
headFragment: opts.headFragment || ''
opts: opts
registerHelpers theme, opts, cssInfo, ctx, this
@generateSimple ctx, jst

72
src/utils/class.js Normal file
View File

@ -0,0 +1,72 @@
/**
Definition of John Resig's `Class` class.
@module class.js
*/
/* Simple JavaScript Inheritance
* By John Resig http://ejohn.org/
* MIT Licensed.
* http://ejohn.org/blog/simple-javascript-inheritance/
*/
// Inspired by base2 and Prototype
(function(){
var initializing = false, fnTest = /xyz/.test(function(){xyz;}) ? /\b_super\b/ : /.*/;
// The base Class implementation (does nothing)
this.Class = function(){};
module.exports = Class;
// Create a new Class that inherits from this class
Class.extend = function(prop) {
var _super = this.prototype;
// Instantiate a base class (but only create the instance,
// don't run the init constructor)
initializing = true;
var prototype = new this();
initializing = false;
// Copy the properties over onto the new prototype
for (var name in prop) {
// Check if we're overwriting an existing function
prototype[name] = typeof prop[name] == "function" &&
typeof _super[name] == "function" && fnTest.test(prop[name]) ?
(function(name, fn){
return function() {
var tmp = this._super;
// Add a new ._super() method that is the same method
// but on the super-class
this._super = _super[name];
// The method only need to be bound temporarily, so we
// remove it when we're done executing
var ret = fn.apply(this, arguments);
this._super = tmp;
return ret;
};
})(name, prop[name]) : // jshint ignore:line
prop[name];
}
// The dummy class constructor
function Class() {
// All construction is actually done in the init method
if ( !initializing && this.init )
this.init.apply(this, arguments);
}
// Populate our constructed prototype object
Class.prototype = prototype;
// Enforce the constructor to be what we expect
Class.prototype.constructor = Class;
// And make this class extendable
Class.extend = arguments.callee;
return Class;
};
})();

View File

@ -0,0 +1,7 @@
###*
Definition of the SyntaxErrorEx class.
@module file-contains.js
###
module.exports = ( file, needle ) ->
require('fs').readFileSync(file,'utf-8').indexOf( needle ) > -1

View File

@ -0,0 +1,49 @@
###*
Definition of the Markdown to WordProcessingML conversion routine.
@license MIT. Copyright (c) 2015 James Devlin / FluentDesk.
@module utils/html-to-wpml
###
_ = require 'underscore'
HTML5Tokenizer = require 'simple-html-tokenizer'
module.exports = ( html ) ->
# Tokenize the HTML stream.
tokens = HTML5Tokenizer.tokenize( html )
final = is_bold = is_italic = is_link = link_url = ''
# Process <em>, <strong>, and <a> elements in the HTML stream, producing
# equivalent WordProcessingML that can be dumped into a <w:p> or other
# text container element.
_.each tokens, ( tok ) ->
switch tok.type
when 'StartTag'
switch tok.tagName
when 'p' then final += '<w:p>'
when 'strong' then is_bold = true
when 'em' then is_italic = true
when 'a'
is_link = true;
link_url = tok.attributes.filter((attr) -> attr[0] == 'href' )[0][1];
when 'EndTag'
switch tok.tagName
when 'p' then final += '</w:p>'
when 'strong' then is_bold = false
when 'em' then is_italic = false
when 'a' then is_link = false
when 'Chars'
if( tok.chars.trim().length )
style = if is_bold then '<w:b/>' else ''
style += if is_italic then '<w:i/>' else ''
style += if is_link then '<w:rStyle w:val="Hyperlink"/>' else ''
final +=
(if is_link then ('<w:hlink w:dest="' + link_url + '">') else '') +
'<w:r><w:rPr>' + style + '</w:rPr><w:t>' + tok.chars +
'</w:t></w:r>' + (if is_link then '</w:hlink>' else '')
final

15
src/utils/md2chalk.coffee Normal file
View File

@ -0,0 +1,15 @@
###*
Inline Markdown-to-Chalk conversion routines.
@license MIT. See LICENSE.md for details.
@module utils/md2chalk
###
MD = require 'marked'
CHALK = require 'chalk'
LO = require 'lodash'
module.exports = ( v, style, boldStyle ) ->
boldStyle = boldStyle || 'bold'
temp = v.replace(/\*\*(.*?)\*\*/g, LO.get( CHALK, boldStyle )('$1'))
if style then LO.get( CHALK, style )(temp) else temp

View File

@ -0,0 +1,58 @@
# Exemplar script for generating documents with Phantom.js.
# https://raw.githubusercontent.com/ariya/phantomjs/master/examples/rasterize.js
# Converted to CoffeeScript by hacksalot
"use strict";
page = require('webpage').create()
system = require('system')
address = output = size = null
if system.args.length < 3 || system.args.length > 5
console.log 'Usage: rasterize.js URL filename [paperwidth*paperheight|paperformat] [zoom]'
console.log ' paper (pdf output) examples: "5in*7.5in", "10cm*20cm", "A4", "Letter"'
console.log ' image (png/jpg output) examples: "1920px" entire page, window width 1920px'
console.log ' "800px*600px" window, clipped to 800x600'
phantom.exit 1
else
address = system.args[1]
output = system.args[2]
page.viewportSize = width: 600, height: 600
if system.args.length > 3 and system.args[2].substr(-4) == ".pdf"
size = system.args[3].split('*')
page.paperSize =
if size.length == 2 then width: size[0], height: size[1], margin: '0px'
else format: system.args[3], orientation: 'portrait', margin: '1cm'
else if system.args.length > 3 && system.args[3].substr(-2) == "px"
size = system.args[3].split '*'
if size.length == 2
pageWidth = parseInt size[0], 10
pageHeight = parseInt size[1], 10
page.viewportSize = width: pageWidth, height: pageHeight
page.clipRect = top: 0, left: 0, width: pageWidth, height: pageHeight
else
console.log "size:", system.args[3]
pageWidth = parseInt system.args[3], 10
pageHeight = parseInt pageWidth * 3/4, 10 # it's as good an assumption as any
console.log "pageHeight:", pageHeight
page.viewportSize = width: pageWidth, height: pageHeight
if system.args.length > 4
page.zoomFactor = system.args[4]
page.open address, (status) ->
if status != 'success'
console.log 'Unable to load the address!'
phantom.exit(1)
return
else
window.setTimeout () ->
page.render(output)
phantom.exit()
return
, 200

View File

@ -0,0 +1,26 @@
###*
Definition of the SafeJsonLoader class.
@module utils/safe-json-loader
@license MIT. See LICENSE.md for details.
###
FS = require('fs')
SyntaxErrorEx = require('./syntax-error-ex')
module.exports = ( file ) ->
ret = { }
try
ret.raw = FS.readFileSync( file, 'utf8' );
ret.json = JSON.parse( ret.raw );
catch
# If we get here, either FS.readFileSync or JSON.parse failed.
# We'll return HMSTATUS.readError or HMSTATUS.parseError.
retRaw = ret.raw && ret.raw.trim()
ret.ex =
operation: if retRaw then 'parse' else 'read'
inner:
if SyntaxErrorEx.is( _error )
then (new SyntaxErrorEx( _error, retRaw ))
else _error
file: file
ret

View File

@ -0,0 +1,28 @@
###*
Safe spawn utility for HackMyResume / FluentCV.
@module utils/safe-spawn
@license MIT. See LICENSE.md for details.
###
module.exports = ( cmd, args, isSync, callback ) ->
try
# .spawnSync not available on earlier Node.js, so default to .spawn
spawn = require('child_process')[ if isSync then 'spawnSync' else 'spawn'];
info = spawn cmd, args
# Check for error depending on whether we're sync or async
if !isSync
info.on 'error', (err) ->
if callback? then callback err; return
else throw cmd: cmd, inner: err
return
else
if info.error
if callback? then callback err; return
else throw cmd: cmd, inner: info.error
catch
if callback? then callback _error
else throw _error

View File

@ -0,0 +1,43 @@
###*
Object string transformation.
@module utils/string-transformer
@license MIT. See LICENSE.md for details.
###
_ = require 'underscore'
moment = require 'moment'
###*
Create a copy of this object in which all string fields have been run through
a transformation function (such as a Markdown filter or XML encoder).
###
module.exports = ( ret, filt, transformer ) ->
that = @
# TODO: refactor recursion
transformStringsInObject = ( obj, filters ) ->
return if !obj
return if moment.isMoment obj
if _.isArray( obj )
obj.forEach (elem, idx, ar) ->
if typeof elem == 'string' || elem instanceof String
ar[idx] = transformer( null, elem )
else if _.isObject(elem)
transformStringsInObject( elem, filters )
else if _.isObject( obj )
Object.keys( obj ).forEach (k) ->
if filters.length && _.contains(filters, k)
return
sub = obj[k]
if typeof sub == 'string' || sub instanceof String
obj[k] = transformer( k, sub )
else if _.isObject( sub )
transformStringsInObject( sub, filters )
Object.keys( ret ).forEach (member) ->
if !filt || !filt.length || !_.contains(filt, member)
transformStringsInObject( ret[ member ], filt || [] )
ret

15
src/utils/string.coffee Normal file
View File

@ -0,0 +1,15 @@
###*
Definitions of string utility functions.
@module utils/string
###
###*
Determine if the string is null, empty, or whitespace.
See: http://stackoverflow.com/a/32800728/4942583
@method isNullOrWhitespace
###
String.isNullOrWhitespace = ( input ) -> !input || !input.trim()
String.prototype.endsWith = (suffix) -> @indexOf(suffix, this.length - suffix.length) != -1
String.is = ( val ) -> typeof val == 'string' || val instanceof String

View File

@ -0,0 +1,25 @@
###*
Definition of the SyntaxErrorEx class.
@module utils/syntax-error-ex
@license MIT. See LICENSE.md for details.
###
###*
Represents a SyntaxError exception with line and column info.
Collect syntax error information from the provided exception object. The
JavaScript `SyntaxError` exception isn't interpreted uniformly across environ-
ments, so we reparse on error to grab the line and column.
See: http://stackoverflow.com/q/13323356
@class SyntaxErrorEx
###
SyntaxErrorEx = ( ex, rawData ) ->
lineNum = null
colNum = null
JSONLint = require 'json-lint'
lint = JSONLint rawData, { comments: false }
this.line = if lint.error then lint.line else '???'
this.col = if lint.error then lint.character else '???'
SyntaxErrorEx.is = ( ex ) -> ex instanceof SyntaxError
module.exports = SyntaxErrorEx;

75
src/verbs/analyze.coffee Normal file
View File

@ -0,0 +1,75 @@
###*
Implementation of the 'analyze' verb for HackMyResume.
@module verbs/analyze
@license MIT. See LICENSE.md for details.
###
MKDIRP = require('mkdirp')
PATH = require('path')
HMEVENT = require('../core/event-codes')
HMSTATUS = require('../core/status-codes')
_ = require('underscore')
ResumeFactory = require('../core/resume-factory')
Verb = require('../verbs/verb')
chalk = require('chalk')
AnalyzeVerb = module.exports = Verb.extend
init: -> @._super 'analyze'
invoke: ->
@.stat HMEVENT.begin, { cmd: 'analyze' }
analyze.apply @, arguments
@.stat HMEVENT.end
###*
Run the 'analyze' command.
###
analyze = ( sources, dst, opts ) ->
if !sources || !sources.length
throw
fluenterror: HMSTATUS.resumeNotFound
quit: true
nlzrs = _loadInspectors()
_.each(sources, (src) ->
result = ResumeFactory.loadOne src, format: 'FRESH', objectify: true, @
if result.fluenterror
this.setError result.fluenterror, result
else
_analyze.call @, result, nlzrs, opts
, @)
###*
Analyze a single resume.
###
_analyze = ( resumeObject, nlzrs, opts ) ->
rez = resumeObject.rez
safeFormat =
if rez.meta and rez.meta.format and rez.meta.format.startsWith 'FRESH'
then 'FRESH' else 'JRS'
this.stat( HMEVENT.beforeAnalyze, { fmt: safeFormat, file: resumeObject.file })
info = _.mapObject nlzrs, (val, key) -> val.run rez
this.stat HMEVENT.afterAnalyze, { info: info }
###*
Load inspectors.
###
_loadInspectors = ->
totals: require '../inspectors/totals-inspector'
coverage: require '../inspectors/gap-inspector'
keywords: require '../inspectors/keyword-inspector'

340
src/verbs/build.coffee Normal file
View File

@ -0,0 +1,340 @@
###*
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. ###
BuildVerb = module.exports = Verb.extend
###* Create a new build verb. ###
init: () -> @._super 'build'
###* Invoke the Build command. ###
invoke: ->
@stat HMEVENT.begin, { cmd: 'build' }
ret = build.apply @, arguments
@stat HMEVENT.end
return ret
###*
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 theme Friendly name of the resume theme. Defaults to "modern".
@param logger Optional logging override.
###
build = ( src, dst, opts ) ->
if !src || !src.length
@err HMSTATUS.resumeNotFound, { quit: true }
prep src, dst, opts
# Load input resumes as JSON...
sheetObjects = ResumeFactory.load(src, {
format: null, objectify: false, quit: true, inner: { sort: _opts.sort }
}, @);
# Explicit check for any resume loading errors...
if !sheetObjects || _.some( sheetObjects, (so) -> return so.fluenterror )
return null
sheets = sheetObjects.map((r) -> return r.json )
# Load the theme...
theme = null
@stat HMEVENT.beforeTheme, { theme: _opts.theme }
try
tFolder = verifyTheme.call @, _opts.theme
theme = _opts.themeObj = loadTheme tFolder
catch ex
newEx =
fluenterror: HMSTATUS.themeLoad
inner: ex
attempted: _opts.theme
this.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 }
## 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
this.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 }
# Add freebie formats to the theme
addFreebieFormats theme
@stat HMEVENT.applyTheme, { r: rez, theme: theme }
# Load the resume into a FRESHResume or JRSResume object
_rezObj = new (RTYPES[ toFormat ])().parseJSON( rez );
# Expand output resumes...
targets = expand( dst, theme );
# Run the transformation!
_.each targets, (t) ->
t.final = single.call this, t, theme, targets
, @
# Don't send the client back empty-handed
sheet: _rezObj
targets: targets
processed: targets
###*
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.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
# 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)
# 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 ) ); # Ensure dest folder exists;
_opts.targets = finished;
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
# Catch any errors caused by generating this file and don't let them
# propagate -- typically we want to continue processing other formats
# even if this format failed.
ex = e;
this.stat HMEVENT.afterGenerate,
fmt: targInfo.fmt.outFormat,
file: PATH.relative( process.cwd(), f ),
error: ex
if ex
if ex.fluenterror
this.err( ex.fluenterror, ex );
else
this.err( HMSTATUS.generateError, { inner: ex } );
return 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 ) ->
tFolder = PATH.join(
parsePath( require.resolve('fresh-themes') ).dirname,
'/themes/',
themeNameOrPath
)
exists = require('path-exists').sync
if !exists( tFolder )
tFolder = PATH.resolve themeNameOrPath
if !exists tFolder
this.err HMSTATUS.themeNotFound, { data: _opts.theme }
tFolder
###*
Load the specified theme, which could be either a FRESH theme or a JSON Resume
theme.
###
loadTheme = ( tFolder ) ->
# Create a FRESH or JRS theme object
theTheme =
if _opts.theme.indexOf('jsonresume-theme-') > -1
then new JRSTheme().open(tFolder) else new FRESHTheme().open( tFolder );
# Cache the theme object
_opts.themeObj = theTheme;
theTheme

77
src/verbs/convert.coffee Normal file
View File

@ -0,0 +1,77 @@
###*
Implementation of the 'convert' verb for HackMyResume.
@module verbs/convert
@license MIT. See LICENSE.md for details.
###
ResumeFactory = require('../core/resume-factory')
chalk = require('chalk')
Verb = require('../verbs/verb')
HMSTATUS = require('../core/status-codes')
_ = require('underscore')
HMEVENT = require('../core/event-codes');
ConvertVerb = module.exports = Verb.extend
init: -> @._super 'convert'
invoke: ->
@.stat HMEVENT.begin, { cmd: 'convert' }
convert.apply @, arguments
@.stat HMEVENT.end
###*
Convert between FRESH and JRS formats.
###
convert = ( srcs, dst, opts ) ->
# Housekeeping
throw { fluenterror: 6, quit: true } if !srcs || !srcs.length
if !dst || !dst.length
if srcs.length == 1
throw { fluenterror: HMSTATUS.inputOutputParity, quit: true };
else if srcs.length == 2
dst = dst || []; dst.push( srcs.pop() )
else
throw fluenterror: HMSTATUS.inputOutputParity, quit: true
if srcs && dst && srcs.length && dst.length && srcs.length != dst.length
throw fluenterror: HMSTATUS.inputOutputParity quit: true
# Load source resumes
_.each(srcs, ( src, idx ) ->
# Load the resume
rinfo = ResumeFactory.loadOne src,
format: null, objectify: true, throw: false
# If a load error occurs, report it and move on to the next file (if any)
if rinfo.fluenterror
this.err rinfo.fluenterror, rinfo
return
s = rinfo.rez
srcFmt =
if ((s.basics && s.basics.imp) || s.imp).orgFormat == 'JRS'
then 'JRS' else 'FRESH'
targetFormat = if srcFmt == 'JRS' then 'FRESH' else 'JRS'
this.stat HMEVENT.beforeConvert,
srcFile: rinfo.file
srcFmt: srcFmt
dstFile: dst[idx]
dstFmt: targetFormat
# Save it to the destination format
s.saveAs dst[idx], targetFormat
return
, @)
return

46
src/verbs/create.coffee Normal file
View File

@ -0,0 +1,46 @@
###*
Implementation of the 'create' verb for HackMyResume.
@module verbs/create
@license MIT. See LICENSE.md for details.
###
MKDIRP = require('mkdirp')
PATH = require('path')
chalk = require('chalk')
Verb = require('../verbs/verb')
_ = require('underscore')
HMSTATUS = require('../core/status-codes')
HMEVENT = require('../core/event-codes')
CreateVerb = module.exports = Verb.extend
init: -> @._super('new')
invoke: ->
@.stat HMEVENT.begin, { cmd: 'create' }
create.apply @, arguments
@.stat HMEVENT.begin, { cmd: 'convert' }
return
###*
Create a new empty resume in either FRESH or JRS format.
###
create = ( src, dst, opts ) ->
if !src || !src.length
throw { fluenterror: HMSTATUS.createNameMissing, quit: true }
_.each( src, ( t ) ->
safeFmt = opts.format.toUpperCase()
@.stat HMEVENT.beforeCreate, { fmt: safeFmt, file: t }
MKDIRP.sync PATH.dirname( t ) # Ensure dest folder exists;
RezClass = require '../core/' + safeFmt.toLowerCase() + '-resume'
RezClass.default().save t
@.stat( HMEVENT.afterCreate, { fmt: safeFmt, file: t } )
, @)
return

67
src/verbs/peek.coffee Normal file
View File

@ -0,0 +1,67 @@
###*
Implementation of the 'peek' verb for HackMyResume.
@module verbs/peek
@license MIT. See LICENSE.md for details.
###
Verb = require('../verbs/verb')
_ = require('underscore')
__ = require('lodash')
safeLoadJSON = require('../utils/safe-json-loader')
HMSTATUS = require('../core/status-codes')
HMEVENT = require('../core/event-codes')
PeekVerb = module.exports = Verb.extend
init: -> @._super('peek')
invoke: ->
@.stat HMEVENT.begin, { cmd: 'peek' }
peek.apply @, arguments
@.stat HMEVENT.end
###* Peek at a resume, resume section, or resume field. ###
peek = ( src, dst, opts ) ->
if !src || !src.length
throw: fluenterror: HMSTATUS.resumeNotFound
objPath = (dst && dst[0]) || ''
_.each src, ( t ) ->
# Fire the 'beforePeek' event 2nd, so we have error/warning/success
@.stat HMEVENT.beforePeek, { file: t, target: objPath }
# Load the input file JSON 1st
obj = safeLoadJSON t
# Fetch the requested object path (or the entire file)
tgt = null;
if !obj.ex
tgt = if objPath then __.get obj.json, objPath else obj.json;
# Fire the 'afterPeek' event with collected info
@.stat HMEVENT.afterPeek,
file: t
requested: objPath
target: tgt
error: obj.ex
# safeLoadJSON can only return a READ error or a PARSE error
if obj.ex
errCode = if obj.ex.operation == 'parse' then HMSTATUS.parseError else HMSTATUS.readError
if errCode == HMSTATUS.readError
obj.ex.quiet = true
@setError errCode, obj.ex
@err errCode, obj.ex
, @
return

92
src/verbs/validate.coffee Normal file
View File

@ -0,0 +1,92 @@
###*
Implementation of the 'validate' verb for HackMyResume.
@module verbs/validate
@license MIT. See LICENSE.md for details.
###
FS = require 'fs'
ResumeFactory = require '../core/resume-factory'
SyntaxErrorEx = require '../utils/syntax-error-ex'
chalk = require 'chalk'
Verb = require '../verbs/verb'
HMSTATUS = require '../core/status-codes'
HMEVENT = require '../core/event-codes'
_ = require 'underscore'
safeLoadJSON = require '../utils/safe-json-loader'
###* An invokable resume validation command. ###
ValidateVerb = module.exports = Verb.extend
init: -> @_super 'validate'
invoke: ->
@stat HMEVENT.begin, { cmd: 'validate' }
ret = validate.apply @, arguments
@stat HMEVENT.end
return ret
###* Validate 1 to N resumes in FRESH or JSON Resume format. ###
validate = (sources, unused, opts) ->
if !sources || !sources.length
throw { fluenterror: HMSTATUS.resumeNotFoundAlt, quit: true }
validator = require 'is-my-json-valid'
schemas =
fresh: require 'fresca'
jars: require '../core/resume.json'
# Validate input resumes. Return a { file: <f>, isValid: <v>} object for
# each resume valid, invalid, or broken.
_.map sources, (t) ->
ret = file: t, isValid: false
# Load the input file JSON 1st
obj = safeLoadJSON t
if !obj.ex
# Successfully read the resume. Now parse it as JSON.
json = obj.json
fmt = if json.basics then 'jrs' else 'fresh'
errors = []
try
validate = validator schemas[ fmt ], { # Note [1]
formats: { date: /^\d{4}(?:-(?:0[0-9]{1}|1[0-2]{1})(?:-[0-9]{2})?)?$/ }
};
ret.isValid = validate json
if !ret.isValid
errors = validate.errors
catch
ret.ex = _error
# safeLoadJSON can only return a READ error or a PARSE error
else
errCode = if obj.ex.operation == 'parse' then HMSTATUS.parseError else HMSTATUS.readError
if errCode == HMSTATUS.readError
obj.ex.quiet = true
@setError errCode, obj.ex
@err errCode, obj.ex
@stat HMEVENT.afterValidate,
file: t
isValid: ret.isValid
fmt: fmt?.replace 'jars', 'JSON Resume'
errors: errors
if opts.assert and !ret.isValid
throw fluenterror: HMSTATUS.invalid, shouldExit: true
console.log '1111'
return ret
, @

71
src/verbs/verb.coffee Normal file
View File

@ -0,0 +1,71 @@
###*
Definition of the Verb class.
@module verbs/verb
@license MIT. See LICENSE.md for details.
###
# Use J. Resig's nifty class implementation
Class = require '../utils/class'
EVENTS = require 'events'
###*
An instantiation of a HackMyResume command.
@class Verb
###
Verb = module.exports = Class.extend
###* Constructor. Automatically called at creation. ###
init: ( moniker ) ->
@.moniker = moniker
@.emitter = new EVENTS.EventEmitter()
return
###* Forward subscriptions to the event emitter. ###
on: ->
this.emitter.on.apply @.emitter, arguments
###* Fire an arbitrary event, scoped to "hmr:". ###
fire: (evtName, payload) ->
payload = payload || { }
payload.cmd = this.moniker
this.emitter.emit 'hmr:' + evtName, payload
true
###* Handle an error condition. ###
err: ( errorCode, payload, hot ) ->
payload = payload || { }
payload.sub = payload.fluenterror = errorCode
payload.throw = hot
this.fire 'error', payload
if hot
throw payload
true
###* Fire the 'hmr:status' error event. ###
stat: ( subEvent, payload ) ->
payload = payload || { }
payload.sub = subEvent
this.fire 'status', payload
true
###* Associate error info with the invocation. ###
setError: ( code, obj ) ->
@errorCode = code
@errorObj = obj
return