diff --git a/.gitignore b/.gitignore index c2658d7..3d972f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +tests/sandbox/ diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..56169c7 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,29 @@ +'use strict'; + +module.exports = function (grunt) { + + var opts = { + + pkg: grunt.file.readJSON('package.json'), + + simplemocha: { + options: { + globals: ['expect', 'should'], + timeout: 3000, + ignoreLeaks: false, + ui: 'bdd', + reporter: 'spec' + }, + all: { src: ['tests/*.js'] } + } + + }; + + grunt.initConfig( opts ); + grunt.loadNpmTasks('grunt-simple-mocha'); + grunt.registerTask('test', 'Test the FluentLib library.', function( config ) { + grunt.task.run( ['simplemocha:all'] ); + }); + grunt.registerTask('default', [ 'test' ]); + +}; diff --git a/package.json b/package.json index 58f0afe..e4ec7ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fluentcmd", - "version": "0.5.0", + "version": "0.6.0", "description": "Generate beautiful, targeted resumes from your command line or shell.", "repository": { "type": "git", @@ -24,10 +24,29 @@ }, "homepage": "https://github.com/fluentdesk/fluentcmd", "dependencies": { - "fluentlib": "fluentdesk/fluentlib#v0.4.0", "minimist": "^1.2.0", "mkdirp": "^0.5.1", "underscore": "^1.8.3", - "watermark": "fluentdesk/watermark#v0.3.1-alpha" - } + "watermark": "fluentdesk/watermark#v0.3.1-alpha", + "fs-extra": "^0.24.0", + "html": "0.0.10", + "is-my-json-valid": "^2.12.2", + "jst": "0.0.13", + "marked": "^0.3.5", + "minimist": "^1.2.0", + "moment": "^2.10.6", + "underscore": "^1.8.3", + "watermark": "fluentdesk/watermark#v0.3.1-alpha", + "wkhtmltopdf": "^0.1.5", + "xml-escape": "^1.0.0", + "yamljs": "^0.2.4" + }, + "devDependencies": { + "chai": "*", + "grunt": "*", + "grunt-simple-mocha": "*", + "is-my-json-valid": "^2.12.2", + "mocha": "*", + "resample": "fluentdesk/resample#v0.1.0-alpha" + } } diff --git a/src/core/empty.json b/src/core/empty.json new file mode 100644 index 0000000..bedfe00 --- /dev/null +++ b/src/core/empty.json @@ -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": [""] + }] +} diff --git a/src/core/fluent-date.js b/src/core/fluent-date.js new file mode 100644 index 0000000..8deaa48 --- /dev/null +++ b/src/core/fluent-date.js @@ -0,0 +1,80 @@ +/** +The FluentCV date representation. +@license Copyright (c) 2015 by James M. Devlin. All rights reserved. +*/ + +var 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 FluentCV "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 +*/ +function FluentDate( dt ) { + this.rep = this.fmt( dt ); +} + +FluentDate/*.prototype*/.fmt = function( dt ) { + if( (typeof dt === 'string' || dt instanceof String) ) { + dt = dt.toLowerCase().trim(); + if( /^(present|now)$/.test(dt) ) { // "Present", "Now" + return moment(); + } + else if( /^\D+\s+\d{4}$/.test(dt) ) { // "Mar 2015" + var parts = dt.split(' '); + var month = (months[parts[0]] || abbr[parts[0]]); + var dt = parts[1] + '-' + (month < 10 ? '0' + month : month.toString()); + return moment( dt, '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}$/.test(dt) ) { // "2015" + return moment( dt, 'YYYY' ); + } + else if( /^\s*$/.test(dt) ) { // "", " " + var defTime = { + isNull: true, + isBefore: function( other ) { + return( other && !other.isNull ) ? true : false; + }, + isAfter: function( other ) { + return( other && !other.isNull ) ? false : false; + }, + unix: function() { return 0; }, + format: function() { return ''; }, + diff: function() { return 0; } + }; + return defTime; + } + else { + var mt = moment( dt ); + if(mt.isValid()) + return mt; + throw 'Invalid date format encountered.'; + } + } + else { + if( dt.isValid && dt.isValid() ) + return dt; + throw 'Unknown date object encountered.'; + } +}; + +var months = {}, abbr = {}; +moment.months().forEach(function(m,idx){months[m.toLowerCase()]=idx+1;}); +moment.monthsShort().forEach(function(m,idx){abbr[m.toLowerCase()]=idx+1;}); +abbr.sept = 9; + +module.exports = FluentDate; diff --git a/src/core/resume.json b/src/core/resume.json new file mode 100644 index 0000000..57bca12 --- /dev/null +++ b/src/core/resume.json @@ -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." + } + } + + } + } + } +} diff --git a/src/core/sheet.js b/src/core/sheet.js new file mode 100644 index 0000000..e3a644b --- /dev/null +++ b/src/core/sheet.js @@ -0,0 +1,259 @@ +/** +Abstract character/resume sheet representation. +@license Copyright (c) 2015 by James M. Devlin. All rights reserved. +*/ + +(function() { + + var FS = require('fs') + , extend = require('../utils/extend') + , validator = require('is-my-json-valid') + , _ = require('underscore') + , PATH = require('path') + , moment = require('moment'); + + /** + The Sheet class represent a specific JSON character sheet. When Sheet.open + is called, we merge the loaded JSON sheet properties onto the Sheet instance + via extend(), so a full-grown sheet object will have all of the methods here, + plus a complement of JSON properties from the backing JSON file. That allows + us to treat Sheet objects interchangeably with the loaded JSON model. + @class Sheet + */ + function Sheet() { + + } + + /** + Open and parse the specified JSON resume sheet. 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. + */ + Sheet.prototype.open = function( file, title ) { + this.meta = { fileName: file }; + this.meta.raw = FS.readFileSync( file, 'utf8' ); + return this.parse( this.meta.raw, title ); + }; + + /** + Save the sheet to disk (for environments that have disk access). + */ + Sheet.prototype.save = function( filename ) { + filename = filename || this.meta.fileName; + FS.writeFileSync( filename, this.stringify(), 'utf8' ); + return this; + }; + + /** + Convert this object to a JSON string, sanitizing meta-properties along the + way. Don't override .toString(). + */ + Sheet.prototype.stringify = function() { + function replacer( key,value ) { // Exclude these keys from stringification + return _.some(['meta', 'warnings', 'computed', 'filt', 'ctrl', 'index', + 'safeStartDate', 'safeEndDate', 'safeDate', 'safeReleaseDate', 'result', + 'isModified', 'htmlPreview'], + function( val ) { return key.trim() === val; } + ) ? undefined : value; + } + return JSON.stringify( this, replacer, 2 ); + }; + + /** + Open and parse the specified JSON resume sheet. 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. + */ + Sheet.prototype.parse = function( stringData, opts ) { + opts = opts || { }; + var rep = JSON.parse( stringData ); + extend( true, this, rep ); + // Set up metadata + if( opts.meta === undefined || opts.meta ) { + this.meta = this.meta || { }; + this.meta.title = (opts.title || this.meta.title) || this.basics.name; + } + // 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) && (this.computed = { + numYears: this.duration(), + keywords: this.keywords() + }); + return this; + }; + + /** + Return a unique list of all keywords across all skills. + */ + Sheet.prototype.keywords = function() { + var flatSkills = []; + if( this.skills && this.skills.length ) { + this.skills.forEach( function( s ) { + flatSkills = _.union( flatSkills, s.keywords ); + }); + } + return flatSkills; + }, + + /** + Update the sheet's raw data. TODO: remove/refactor + */ + Sheet.prototype.updateData = function( str ) { + this.clear( false ); + this.parse( str ) + return this; + }; + + /** + Reset the sheet to an empty state. + */ + Sheet.prototype.clear = function( clearMeta ) { + clearMeta = ((clearMeta === undefined) && true) || clearMeta; + clearMeta && (delete this.meta); + delete this.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; + }; + + /** + Get the default (empty) sheet. + */ + Sheet.default = function() { + return new Sheet().open( PATH.join( __dirname, 'empty.json'), 'Empty' ); + } + + /** + Add work experience to the sheet. + */ + Sheet.prototype.add = function( moniker ) { + var defSheet = Sheet.default(); + var newObject = $.extend( true, {}, defSheet[ moniker ][0] ); + this[ moniker ] = this[ moniker ] || []; + this[ moniker ].push( newObject ); + return newObject; + }; + + /** + Determine if the sheet includes a specific social profile (eg, GitHub). + */ + Sheet.prototype.hasProfile = function( socialNetwork ) { + socialNetwork = socialNetwork.trim().toLowerCase(); + return this.basics.profiles && _.some( this.basics.profiles, function(p) { + return p.network.trim().toLowerCase() === socialNetwork; + }); + }; + + /** + Determine if the sheet includes a specific skill. + */ + Sheet.prototype.hasSkill = function( skill ) { + skill = skill.trim().toLowerCase(); + return this.skills && _.some( this.skills, function(sk) { + return sk.keywords && _.some( sk.keywords, function(kw) { + return kw.trim().toLowerCase() === skill; + }); + }); + }; + + /** + Validate the sheet against the JSON Resume schema. + */ + Sheet.prototype.isValid = function( ) { // TODO: ↓ fix this path ↓ + var schema = FS.readFileSync( PATH.join( __dirname, 'resume.json' ), 'utf8' ); + var schemaObj = JSON.parse( schema ); + var validator = require('is-my-json-valid') + var validate = validator( schemaObj ); + return validate( this ); + }; + + /** + 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. + */ + Sheet.prototype.duration = function() { + if( this.work && this.work.length ) { + var careerStart = this.work[ this.work.length - 1].safeStartDate; + if ((typeof careerStart === 'string' || careerStart instanceof String) && + !careerStart.trim()) + return 0; + var careerLast = _.max( this.work, function( w ) { + return w.safeEndDate.unix(); + }).safeEndDate; + return careerLast.diff( careerStart, 'years' ); + } + return 0; + }; + + /** + Sort dated things on the sheet by start date descending. Assumes that dates + on the sheet have been processed with _parseDates(). + */ + Sheet.prototype.sort = function( ) { + + this.work && this.work.sort( byDateDesc ); + this.education && this.education.sort( byDateDesc ); + this.volunteer && this.volunteer.sort( byDateDesc ); + + this.awards && this.awards.sort( function(a, b) { + return( a.safeDate.isBefore(b.safeDate) ) ? 1 + : ( a.safeDate.isAfter(b.safeDate) && -1 ) || 0; + }); + this.publications && this.publications.sort( function(a, b) { + return( a.safeReleaseDate.isBefore(b.safeReleaseDate) ) ? 1 + : ( a.safeReleaseDate.isAfter(b.safeReleaseDate) && -1 ) || 0; + }); + + function byDateDesc(a,b) { + return( a.safeStartDate.isBefore(b.safeStartDate) ) ? 1 + : ( a.safeStartDate.isAfter(b.safeStartDate) && -1 ) || 0; + } + + }; + + /** + 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. + */ + function _parseDates() { + + var _fmt = require('./fluent-date').fmt; + + this.work && this.work.forEach( function(job) { + job.safeStartDate = _fmt( job.startDate ); + job.safeEndDate = _fmt( job.endDate ); + }); + this.education && this.education.forEach( function(edu) { + edu.safeStartDate = _fmt( edu.startDate ); + edu.safeEndDate = _fmt( edu.endDate ); + }); + this.volunteer && this.volunteer.forEach( function(vol) { + vol.safeStartDate = _fmt( vol.startDate ); + vol.safeEndDate = _fmt( vol.endDate ); + }); + this.awards && this.awards.forEach( function(awd) { + awd.safeDate = _fmt( awd.date ); + }); + this.publications && this.publications.forEach( function(pub) { + pub.safeReleaseDate = _fmt( pub.releaseDate ); + }); + } + + /** + Export the Sheet function/ctor. + */ + module.exports = Sheet; + +}()); diff --git a/src/core/theme.js b/src/core/theme.js new file mode 100644 index 0000000..4150d0c --- /dev/null +++ b/src/core/theme.js @@ -0,0 +1,90 @@ +/** +Abstract theme representation. +@license MIT. Copyright (c) 2015 James Devlin / FluentDesk. +*/ + +(function() { + + var FS = require('fs') + , extend = require('../utils/extend') + , validator = require('is-my-json-valid') + , _ = require('underscore') + , PATH = require('path') + , moment = require('moment'); + + /** + The Theme class represents a specific presentation of a resume. + @class Theme + */ + function Theme() { + + } + + /** + Open and parse the specified theme. + */ + Theme.prototype.open = function( themeFolder ) { + + function friendlyName( val ) { + val = val.trim().toLowerCase(); + var friendly = { yml: 'yaml', md: 'markdown', txt: 'text' }; + return friendly[val] || val; + } + + var tplFolder = PATH.join( themeFolder, 'templates' ); + var fmts = FS.readdirSync( tplFolder ).map( function( file ) { + var absPath = PATH.join( tplFolder, file ); + var pathInfo = PATH.parse(absPath); + var temp = [ pathInfo.name, { + title: friendlyName(pathInfo.name), + pre: pathInfo.name, + ext: pathInfo.ext.slice(1), + path: absPath, + data: FS.readFileSync( absPath, 'utf8' ), + css: null + }]; + return temp; + }); + + // Freebie formats every theme gets + fmts.push( [ 'json', { title: 'json', pre: 'json', ext: 'json', path: null, data: null } ] ); + fmts.push( [ 'yml', { title: 'yaml', pre: 'yml', ext: 'yml', path: null, data: null } ] ); + + // Handle CSS files + var cssFiles = fmts.filter(function( fmt ){ + return fmt[1].ext === 'css'; + }); + cssFiles.forEach(function( cssf ) { + // For each CSS file, get its corresponding HTML file + var idx = _.findIndex(fmts, function( fmt ) { return fmt[1].pre === cssf[1].pre && fmt[1].ext === 'html' }); + fmts[ idx ][1].css = cssf[1].data; + fmts[ idx ][1].cssPath = cssf[1].path; + }); + fmts = fmts.filter( function( fmt) { + return fmt[1].ext !== 'css'; + }); + + // Create a hash out of the formats for this theme + this.formats = _.object( fmts ); + + this.name = PATH.parse( themeFolder ).name; + return this; + }; + + /** + Determine if the theme supports the specified output format. + */ + Theme.prototype.hasFormat = function( fmt ) { + return _.has( this.formats, fmt ); + }; + + /** + Determine if the theme supports the specified output format. + */ + Theme.prototype.getFormat = function( fmt ) { + return this.formats[ fmt ]; + }; + + module.exports = Theme; + +}()); diff --git a/src/fluentcmd.js b/src/fluentcmd.js index 6410b47..8564db9 100644 --- a/src/fluentcmd.js +++ b/src/fluentcmd.js @@ -11,7 +11,7 @@ module.exports = function () { , unused = require('./utils/string') , fs = require('fs') , _ = require('underscore') - , FLUENT = require('fluentlib') + , FLUENT = require('./fluentlib') , PATH = require('path') , MKDIRP = require('mkdirp') , rez, _log, _err; diff --git a/src/fluentlib.js b/src/fluentlib.js new file mode 100644 index 0000000..f442705 --- /dev/null +++ b/src/fluentlib.js @@ -0,0 +1,17 @@ +/** +Core resume generation module for FluentCV. +@license Copyright (c) 2015 by James M. Devlin. All rights reserved. +*/ + +module.exports = { + Sheet: require('./core/sheet'), + Theme: require('./core/theme'), + HtmlGenerator: require('./gen/html-generator'), + TextGenerator: require('./gen/text-generator'), + HtmlPdfGenerator: require('./gen/html-pdf-generator'), + WordGenerator: require('./gen/word-generator'), + MarkdownGenerator: require('./gen/markdown-generator'), + JsonGenerator: require('./gen/json-generator'), + YamlGenerator: require('./gen/yaml-generator'), + JsonYamlGenerator: require('./gen/json-yaml-generator') +}; diff --git a/src/gen/base-generator.js b/src/gen/base-generator.js new file mode 100644 index 0000000..0a239f1 --- /dev/null +++ b/src/gen/base-generator.js @@ -0,0 +1,43 @@ +/** +Base resume generator for FluentCV. +@license Copyright (c) 2015 | James M. Devlin +*/ + +(function() { + + // Use J. Resig's nifty class implementation + var Class = require( '../utils/class' ); + + /** + The BaseGenerator class is the root of the generator hierarchy. Functionality + common to ALL generators lives here. + */ + + var BaseGenerator = module.exports = Class.extend({ + + /** + Base-class initialize. + */ + init: function( outputFormat ) { + this.format = outputFormat; + }, + + /** + Status codes. + */ + codes: { + success: 0, + themeNotFound: 1, + copyCss: 2, + resumeNotFound: 3 + }, + + /** + Generator options. + */ + opts: { + + } + + }); +}()); diff --git a/src/gen/html-generator.js b/src/gen/html-generator.js new file mode 100644 index 0000000..1522b70 --- /dev/null +++ b/src/gen/html-generator.js @@ -0,0 +1,32 @@ +/** +HTML resume generator for FluentCV. +@license Copyright (c) 2015 by James M. Devlin. All rights reserved. +*/ + +var TemplateGenerator = require('./template-generator'); +var FS = require('fs-extra'); +var HTML = require( 'html' ); + +var HtmlGenerator = module.exports = TemplateGenerator.extend({ + + init: function() { + this._super( 'html' ); + }, + + /** + Generate an HTML resume with optional pretty printing. + */ + onBeforeSave: function( mk, theme, outputFile ) { + var themeFile = theme.getFormat('html').path; + var cssSrc = themeFile.replace( /.html$/g, '.css' ); + var cssDst = outputFile.replace( /.html$/g, '.css' ); + var that = this; + FS.copySync( cssSrc, cssDst, { clobber: true }, function( e ) { + throw { fluenterror: that.codes.copyCss, data: [cssSrc,cssDst] }; + }); + + return this.opts.prettify ? + HTML.prettyPrint( mk, this.opts.prettify ) : mk; + } + +}); diff --git a/src/gen/html-pdf-generator.js b/src/gen/html-pdf-generator.js new file mode 100644 index 0000000..549c787 --- /dev/null +++ b/src/gen/html-pdf-generator.js @@ -0,0 +1,74 @@ +/** +HTML-based PDF resume generator for FluentCV. +@license Copyright (c) 2015 by James M. Devlin. All rights reserved. +*/ + +var TemplateGenerator = require('./template-generator'); +var FS = require('fs-extra'); +var HTML = require( 'html' ); + +var HtmlPdfGenerator = module.exports = TemplateGenerator.extend({ + + init: function() { + this._super( 'pdf', 'html' ); + }, + + /** + Generate an HTML resume with optional pretty printing. + TODO: Avoid copying the CSS file to dest if we don't need to. + */ + onBeforeSave: function( mk, themeFile, outputFile ) { + // var cssSrc = themeFile.replace( /pdf\.html$/gi, 'html.css' ); + // var cssDst = outputFile.replace( /\.pdf$/gi, '.css' ); + // var that = this; + // FS.copySync( cssSrc, cssDst, { clobber: true }, function( e ) { + // if( e ) that.err( "Couldn't copy CSS file to destination: " + e); + // }); + // return true ? + // HTML.prettyPrint( mk, { indent_size: 2 } ) : mk; + + pdf(mk, outputFile); + return mk; + } + +}); + +/** +Generate a PDF from HTML. +*/ +function pdf( markup, fOut ) { + + var pdfCount = 0; + if( false ) { //( _opts.pdf === 'phantom' || _opts.pdf == 'all' ) { + pdfCount++; + require('phantom').create( function( ph ) { + ph.createPage( function( page ) { + page.setContent( markup ); + page.set('paperSize', { + format: 'A4', + orientation: 'portrait', + margin: '1cm' + }); + page.set("viewportSize", { + width: 1024, // TODO: option-ify + height: 768 // TODO: Use "A" sizes + }); + page.set('onLoadFinished', function(success) { + page.render( fOut ); + pdfCount++; + ph.exit(); + }); + }, + { dnodeOpts: { weak: false } } ); + }); + } + if( true ) { // _opts.pdf === 'wkhtmltopdf' || _opts.pdf == 'all' ) { + var fOut2 = fOut; + if( pdfCount == 1 ) { + fOut2 = fOut2.replace(/\.pdf$/g, '.b.pdf'); + } + require('wkhtmltopdf')( markup, { pageSize: 'letter' } ) + .pipe( FS.createWriteStream( fOut2 ) ); + pdfCount++; + } +} diff --git a/src/gen/json-generator.js b/src/gen/json-generator.js new file mode 100644 index 0000000..218d45b --- /dev/null +++ b/src/gen/json-generator.js @@ -0,0 +1,35 @@ +/** +Definition of the JsonGenerator class. +@license Copyright (c) 2015 | James M. Devlin +*/ + +var BaseGenerator = require('./base-generator'); +var FS = require('fs'); +var _ = require('underscore'); + +/** +The JsonGenerator generates a JSON resume directly. +*/ +var JsonGenerator = module.exports = BaseGenerator.extend({ + + init: function(){ + this._super( 'json' ); + }, + + invoke: function( rez ) { + // TODO: merge with FCVD + function replacer( key,value ) { // Exclude these keys from stringification + return _.some(['meta', 'warnings', 'computed', 'filt', 'ctrl', 'index', + 'safeStartDate', 'safeEndDate', 'safeDate', 'safeReleaseDate', 'result', + 'isModified', 'htmlPreview'], + function( val ) { return key.trim() === val; } + ) ? undefined : value; + } + return JSON.stringify( rez, replacer, 2 ); + }, + + generate: function( rez, f ) { + FS.writeFileSync( f, this.invoke(rez), 'utf8' ); + } + +}); diff --git a/src/gen/json-yaml-generator.js b/src/gen/json-yaml-generator.js new file mode 100644 index 0000000..c198af8 --- /dev/null +++ b/src/gen/json-yaml-generator.js @@ -0,0 +1,37 @@ +/** +A JSON-driven YAML resume generator for FluentLib. +@module json-yaml-generator.js +@license MIT. Copyright (c) 2015 James Devlin / FluentDesk +*/ + +(function() { + + var BaseGenerator = require('./base-generator'); + var FS = require('fs'); + var 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). + */ + + var JsonYamlGenerator = module.exports = BaseGenerator.extend({ + + init: function(){ + this._super( 'yml' ); + }, + + invoke: function( rez, themeMarkup, cssInfo, opts ) { + return YAML.stringify( JSON.parse( rez.stringify() ), Infinity, 2 ); + }, + + generate: function( rez, f, opts ) { + var data = YAML.stringify( JSON.parse( rez.stringify() ), Infinity, 2 ); + FS.writeFileSync( f, data, 'utf8' ); + } + + + }); + +}()); diff --git a/src/gen/markdown-generator.js b/src/gen/markdown-generator.js new file mode 100644 index 0000000..b200e55 --- /dev/null +++ b/src/gen/markdown-generator.js @@ -0,0 +1,17 @@ +/** +Markdown resume generator for FluentCV. +@license Copyright (c) 2015 by James M. Devlin. All rights reserved. +*/ + +var TemplateGenerator = require('./template-generator'); + +/** +MarkdownGenerator generates a Markdown-formatted resume via TemplateGenerator. +*/ +var MarkdownGenerator = module.exports = TemplateGenerator.extend({ + + init: function(){ + this._super( 'md', 'txt' ); + } + +}); diff --git a/src/gen/template-generator.js b/src/gen/template-generator.js new file mode 100644 index 0000000..0c8ba98 --- /dev/null +++ b/src/gen/template-generator.js @@ -0,0 +1,173 @@ +/** +Template-based resume generator base for FluentCV. +@license Copyright (c) 2015 | James M. Devlin +*/ + +var FS = require( 'fs' ) + , _ = require( 'underscore' ) + , MD = require( 'marked' ) + , XML = require( 'xml-escape' ) + , PATH = require('path') + , BaseGenerator = require( './base-generator' ) + , EXTEND = require('../utils/extend') + , Theme = require('../core/theme'); + +// Default options. +var _defaultOpts = { + themeRelative: '../../node_modules/watermark/themes', + keepBreaks: true, + freezeBreaks: true, + nSym: '&newl;', // newline entity + rSym: '&retn;', // return entity + template: { + interpolate: /\{\{(.+?)\}\}/g, + escape: /\{\{\=(.+?)\}\}/g, + evaluate: /\{\%(.+?)\%\}/g, + comment: /\{\#(.+?)\#\}/g + }, + filters: { + out: function( txt ) { return txt; }, + raw: function( txt ) { return txt; }, + xml: function( txt ) { return XML(txt); }, + md: function( txt ) { return MD(txt); }, + mdin: function( txt ) { return MD(txt).replace(/^\s*\
|\<\/p\>\s*$/gi, ''); }, + lower: function( txt ) { return txt.toLowerCase(); } + }, + 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 + } +}; + +/** +TemplateGenerator performs resume generation via Underscore-style template +expansion and is appropriate for text-based formats like HTML, plain text, +and XML versions of Microsoft Word, Excel, and OpenOffice. +*/ +var TemplateGenerator = module.exports = BaseGenerator.extend({ + + /** outputFormat: html, txt, pdf, doc + templateFormat: html or txt + **/ + init: function( outputFormat, templateFormat, cssFile ){ + this._super( outputFormat ); + this.tplFormat = templateFormat || outputFormat; + }, + + /** Default generation method for template-based generators. */ + invoke: function( rez, themeMarkup, cssInfo, opts ) { + + // Compile and invoke the template! + this.opts = EXTEND( true, {}, _defaultOpts, opts ); + mk = this.single( rez, themeMarkup, this.format, cssInfo, { } ); + this.onBeforeSave && (mk = this.onBeforeSave( mk, themeFile, f )); + return mk; + + }, + + /** Default generation method for template-based generators. */ + generate: function( rez, f, opts ) { + + // Carry over options + this.opts = EXTEND( true, { }, _defaultOpts, opts ); + + // Verify the specified theme name/path + var tFolder = PATH.resolve( __dirname, this.opts.themeRelative, this.opts.theme ); + var exists = require('../utils/file-exists'); + if (!exists( tFolder )) { + tFolder = PATH.resolve( this.opts.theme ); + if (!exists( tFolder )) { + throw { fluenterror: this.codes.themeNotFound, data: this.opts.theme }; + } + } + + // Load the theme + var theme = opts.themeObj || new Theme().open( tFolder ); + + // Load theme and CSS data + var tplFolder = PATH.join( tFolder, 'templates' ); + var curFmt = theme.getFormat( this.format ); + var ctx = { file: curFmt.css ? curFmt.cssPath : null, data: curFmt.css || null }; + + // Compile and invoke the template! + var mk = this.single( rez, curFmt.data, this.format, ctx, opts ); + this.onBeforeSave && (mk = this.onBeforeSave( mk, theme, f )); + FS.writeFileSync( f, mk, { encoding: 'utf8', flags: 'w' } ); + + }, + + /** + Perform a single resume JSON-to-DEST resume transformation. Exists as a + separate function in order to expose string-based transformations to clients + who don't have access to filesystem resources (in-browser, etc.). + */ + single: function( json, jst, format, cssInfo, opts ) { + + // Freeze whitespace in the template. + this.opts.freezeBreaks && ( jst = freeze(jst) ); + + // Tweak underscore's default template delimeters + _.templateSettings = this.opts.template; + + // Convert {{ someVar }} to {% print(filt.out(someVar) %} + // Convert {{ someVar|someFilter }} to {% print(filt.someFilter(someVar) %} + jst = jst.replace( _.templateSettings.interpolate, function replace(m, p1) { + if( p1.indexOf('|') > -1 ) { + var terms = p1.split('|'); + return '{% print( filt.' + terms[1] + '( ' + terms[0] + ' )) %}'; + } + else { + return '{% print( filt.out(' + p1 + ') ) %}'; + } + }); + + // Strip {# comments #} + jst = jst.replace( _.templateSettings.comment, ''); + json.display_progress_bar = true; + + // Compile and run the template. TODO: avoid unnecessary recompiles. + jst = _.template(jst)({ r: json, filt: this.opts.filters, cssInfo: cssInfo, headFragment: this.opts.headFragment || '' }); + + // Unfreeze whitespace + this.opts.freezeBreaks && ( jst = unfreeze(jst) ); + + return jst; + } + + +}); + +/** +Export the TemplateGenerator function/ctor. +*/ +module.exports = TemplateGenerator; + +/** +Freeze newlines for protection against errant JST parsers. +*/ +function freeze( markup ) { + return markup + .replace( _reg.regN, _defaultOpts.nSym ) + .replace( _reg.regR, _defaultOpts.rSym ); +} + +/** +Unfreeze newlines when the coast is clear. +*/ +function unfreeze( markup ) { + return markup + .replace( _reg.regSymR, '\r' ) + .replace( _reg.regSymN, '\n' ); +} + +/** +Regexes for linebreak preservation. +*/ +var _reg = { + regN: new RegExp( '\n', 'g' ), + regR: new RegExp( '\r', 'g' ), + regSymN: new RegExp( _defaultOpts.nSym, 'g' ), + regSymR: new RegExp( _defaultOpts.rSym, 'g' ) +}; diff --git a/src/gen/text-generator.js b/src/gen/text-generator.js new file mode 100644 index 0000000..5aa077b --- /dev/null +++ b/src/gen/text-generator.js @@ -0,0 +1,19 @@ +/** +Plain text resume generator for FluentCV. +@license Copyright (c) 2015 by James M. Devlin. All rights reserved. +*/ + +var TemplateGenerator = require('./template-generator'); + +/** +The TextGenerator generates a plain-text resume via the TemplateGenerator. +*/ +var TextGenerator = TemplateGenerator.extend({ + + init: function(){ + this._super( 'txt' ); + }, + +}); + +module.exports = TextGenerator; diff --git a/src/gen/word-generator.js b/src/gen/word-generator.js new file mode 100644 index 0000000..db6cbd8 --- /dev/null +++ b/src/gen/word-generator.js @@ -0,0 +1,13 @@ +/** +MS Word resume generator for FluentCV. +@license Copyright (c) 2015 by James M. Devlin. All rights reserved. +*/ + +var TemplateGenerator = require('./template-generator'); +var WordGenerator = module.exports = TemplateGenerator.extend({ + + init: function(){ + this._super( 'doc', 'xml' ); + }, + +}); diff --git a/src/gen/xml-generator.js b/src/gen/xml-generator.js new file mode 100644 index 0000000..593f47c --- /dev/null +++ b/src/gen/xml-generator.js @@ -0,0 +1,17 @@ +/** +XML resume generator for FluentCV. +@license Copyright (c) 2015 | James M. Devlin +*/ + +var BaseGenerator = require('./base-generator'); + +/** +The XmlGenerator generates an XML resume via the TemplateGenerator. +*/ +var XmlGenerator = module.exports = BaseGenerator.extend({ + + init: function(){ + this._super( 'xml' ); + }, + +}); diff --git a/src/gen/yaml-generator.js b/src/gen/yaml-generator.js new file mode 100644 index 0000000..000198c --- /dev/null +++ b/src/gen/yaml-generator.js @@ -0,0 +1,24 @@ +/** +A YAML resume generator for FluentLib. +@module yaml-generator.js +@license MIT. Copyright (c) 2015 James Devlin / FluentDesk +*/ + + +(function() { + + var TemplateGenerator = require('./template-generator'); + + /** + YamlGenerator generates a YAML-formatted resume via TemplateGenerator. + */ + + var YamlGenerator = module.exports = TemplateGenerator.extend({ + + init: function(){ + this._super( 'yml', 'yml' ); + } + + }); + +}()); diff --git a/src/utils/class.js b/src/utils/class.js new file mode 100644 index 0000000..2640865 --- /dev/null +++ b/src/utils/class.js @@ -0,0 +1,67 @@ +/* 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]) : + 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; + }; + +})(); diff --git a/src/utils/extend.js b/src/utils/extend.js index 3c13cdd..6f08191 100644 --- a/src/utils/extend.js +++ b/src/utils/extend.js @@ -1,6 +1,6 @@ /** Plain JavaScript replacement of jQuery .extend based on jQuery sources. -@license Copyright (c) 2015 | James M. Devlin +@license Copyright (c) 2015 by James M. Devlin. All rights reserved. */ function _extend() { diff --git a/tests/test-sheet.js b/tests/test-sheet.js new file mode 100644 index 0000000..454a933 --- /dev/null +++ b/tests/test-sheet.js @@ -0,0 +1,67 @@ + +var chai = require('chai') + , expect = chai.expect + , should = chai.should() + , path = require('path') + , _ = require('underscore') + , Sheet = require('../src/core/sheet') + , validator = require('is-my-json-valid'); + +chai.config.includeStack = false; + +describe('fullstack.json', function () { + + var _sheet; + + it('should open without throwing an exception', function () { + function tryOpen() { + _sheet = new Sheet().open( 'node_modules/resample/fullstack/in/resume.json' ); + } + tryOpen.should.not.Throw(); + }); + + it('should have one or more of each section', function() { + expect( + (_sheet.basics) && + (_sheet.work && _sheet.work.length > 0) && + (_sheet.skills && _sheet.skills.length > 0) && + (_sheet.education && _sheet.education.length > 0) && + (_sheet.volunteer && _sheet.volunteer.length > 0) && + (_sheet.publications && _sheet.publications.length > 0) && + (_sheet.awards && _sheet.awards.length > 0) + ).to.equal( true ); + }); + + it('should have a work duration of 11 years', function() { + _sheet.computed.numYears.should.equal( 11 ); + }); + + it('should save without throwing an exception', function(){ + function trySave() { + _sheet.save( 'tests/sandbox/fullstack.json' ); + } + trySave.should.not.Throw(); + }); + + it('should not be modified after saving', function() { + var savedSheet = new Sheet().open( 'tests/sandbox/fullstack.json' ); + _sheet.stringify().should.equal( savedSheet.stringify() ) + }); + + it('should validate against the JSON Resume schema', function() { + var schemaJson = require('../src/core/resume.json'); + var validate = validator( schemaJson, { verbose: true } ); + var result = validate( JSON.parse( _sheet.meta.raw ) ); + result || console.log("\n\nOops, resume didn't validate. " + + "Validation errors:\n\n", validate.errors, "\n\n"); + result.should.equal( true ); + }); + + +}); + +// describe('subtract', function () { +// it('should return -1 when passed the params (1, 2)', function () { +// expect(math.subtract(1, 2)).to.equal(-1); +// }); +// });