Permalink
Switch branches/tags
Nothing to show
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
798 lines (618 sloc) 25.4 KB
fs = require 'fs'
path = require 'path'
{parse} = require 'url'
{EventEmitter} = require 'events'
{exec, spawn} = require 'child_process'
Seq = require 'seq'
/**
* Sets up an instance of the CompilerMiddleware.
*
* @param {Object} [settings={}]
* @param {Compiler[]} [...custom]
* @returns {Function}
*/
exports = module.exports = setup = (settings={}, ...custom) ->
cmw = new CompilerMiddleware(settings, custom)
cmw.respond
exports.setup = setup
/** All known compilers, by id. */
exports.compilers = compilers = { __proto__: null }
/** Default settings. */
exports.DEFAULTS = DEFAULTS =
enabled : [] # compiler_id[]
src : null # str | str[] -- default: CWD
dest : null # str -- default: src
roots : null # src to dest pairs: { src:dest, ... } | [[src, dest], ...]
mount : '' # prefix trimmed off request path
delta : null # delta secs of mtime for file to be stale
expires : false # automatically treat files as stale if this old in secs
log_level : 'WARN' # Logging verbosity.
create_dirs : true # if dest dir is missing, create it?
external_timeout : 3000 # ms after which to kill subprocess commands
cascade : false # invoke all compilers that match? otherwise, only first.
ignore : /\.(jpe?g|gif|png)$/i # Requests matching this pattern are ignored, and no compiler matching occurs.
resolve_index : false # if true-y, resolve directories to this filename: false | true => 'index.html' | str
allowed_methods : [ 'GET', 'HEAD' ] # HTTP methods compiler should process
# TODO (maybe)
# on : null # event handlers to add: { event : handler, ... }
# settings object is passed to compilers and all keys are preserved
options : # additional options can be specified per-compiler
all : {} # options to merge into options for all compilers (in case of key conflict with settings)
/** Log Levels */
exports.LOG = LOG =
levelToString : (level) ->
for name, val in LOG
return name if val is level
String level
stringToLevel : (level) ->
return level unless typeof level is 'string'
level .= toUpperCase()
for name, val in LOG
return val if name is level
0
SILENT : Infinity
ERROR : 40
WARN : 30
INFO : 20
DEBUG : 10
/**
* CompilerMiddleware class.
* @class
*/
class exports.CompilerMiddleware extends EventEmitter
/**
* @constructor
* @param {Object} [settings={}] Settings object.
* @param {Compiler[]} [custom=[]] List of custom compilers to add.
*/
(settings={}, @custom=[]) ->
super()
@respond.=bind this
@settings = settings = {} <<< DEFAULTS <<< settings
if not settings.enabled or settings.enabled.length is 0
throw new Error "You must supply a list of enabled compilers!"
if typeof settings.enabled is 'string'
settings.enabled = [ settings.enabled ]
unless settings.roots
srcDirs = delete settings.src or process.cwd()
srcDirs = [srcDirs] unless Array.isArray srcDirs
destDir = delete settings.dest or srcDirs[0]
settings.roots = srcDirs.map -> [it, destDir]
unless Array.isArray settings.roots
settings.roots = ( [src, dest] for src, dest in settings.roots )
if settings.resolve_index is true
settings.resolve_index = 'index.html'
if settings.resolve_index
settings.resolve_index .= trimLeft '/'
settings.log_level = LOG.stringToLevel settings.log_level
if settings.log_level <= LOG.DEBUG
console.log 'compiler.setup()'
console.dir settings
console.log ''
respond: (req, res, next) ->
settings = @settings
if settings.allowed_methods.indexOf(req.method) is -1 or settings.ignore?.test req.url
return next()
request =
req : req
res : res
next : next
url : req.url
path : parse(req.url).pathname
request.basename = path.basename request.path
if settings.mount and request.path.indexOf(settings.mount) is 0
request.path.=slice(settings.mount.length)
if settings.resolve_index and /\/$/.test request.path
request.path = path.join request.path, settings.resolve_index
info = {} <<< settings <<< request <<<
settings : settings
request : request
cwd : process.cwd()
matches : 0
success = false
log_prefix = ">>>> [compiler]"
console.log "#log_prefix Looking up compilers for '#{request.path}'..." if settings.log_level <= LOG.DEBUG
try
Seq(settings.enabled)
.seqEach (id, i) ->
C = compilers[id]
return this(null) unless C and (not success or settings.cascade)
console.log "\n#log_prefix (#i) Checking '#id'..." if settings.log_level <= LOG.DEBUG
info.compiler = C
info.id = id
_info = {} <<< info <<< (info.options?[id] or {})
C.run _info, (err, ok) ~>
if not err and ok
success := ok
info.matches++
console.log "#log_prefix Completed '#id'! (ok=#ok, err=#err) --> success=#success" if settings.log_level <= LOG.DEBUG
this null
.seq ->
console.log "#log_prefix Done! (success=#success)" if settings.log_level <= LOG.DEBUG
next()
.catch (err) ->
console.log "#log_prefix Error! ", err if err if settings.log_level <= LOG.ERROR
@die()
next()
catch err
if settings.log_level <= LOG.ERROR
console.log "#log_prefix Caught Err!", (if err.stack then '' else err)
console.log that if err.stack
next()
void
/**
* To create a new Compiler, extend `Compiler` or any other existing compiler, and then call `register(NewCompiler)`.
* @param {Compiler} Compiler to register.
* @returns The passed compiler.
*/
exports.register = register = (NewCompiler) ->
return if not NewCompiler
proto = NewCompiler::
return NewCompiler if proto.hasOwnProperty('__abstract__')
id = proto.id
name = NewCompiler.displayName or NewCompiler.name or id
unless id
throw new Error "Compiler #name must have a valid id (not '#id')!"
NewCompiler.id = id
old = compilers[id]
if old and old is not NewCompiler
throw new Error "Compiler id collision ('#id'): new=#name is not old=#{old.displayName or old.name or old.id}!"
unless proto.compile or proto.compileSync
throw new Error "Compiler #name missing a compile/compileSync method!"
Superclass = NewCompiler.superclass or proto.constructor
NewCompiler.run ?= Superclass.run or Compiler.run
NewCompiler.extended ?= Superclass.extended or Compiler.extended
# NewCompiler.extended or= arguments.callee
compilers[id] = NewCompiler
### Compilers
/**
* Root compiler class.
*
* @class
*/
class exports.Compiler extends EventEmitter
id : '' # [required] unique id used to enable compiler.
match : /(?:\.mod)?(\.min)?\.js$/i # pattern used by `matches()` to test request path and to create source path (with `ext`).
ext : '' # replacement pattern used by `matches()` to create source path (with `match`).
destExt : null # extension used for the rendered file. if false, the file will match the request pathname.
module : null # module for this compiler; if string and true-y, module will be `require`-ed.
options : null # options passed to `compile()` or `compileSync()` call as second argument if present; if it is a function, it will be called
wraps : false # compiler id of which to wrap the output
info : null
wrapped : null
/**
* @constructor
* @param {Object} info Request info merged with settings. (Pointer, not copy.)
*/
(@info) ->
super()
for own k, v in this
@[k] = v.bind(this) if typeof v is 'function'
mod = @module
@module = require mod if mod and typeof mod is 'string'
log: (level, ...msgs) ->
if @info.log_level <= level
level_name = if LOG.levelToString level then that else ''
compiler = String(this)
compiler += if compiler.length < 8 then '\t' else ''
file = @info.path #@info.basename
len = file.length
while len < 48
len += 8
file += '\t'
# file += '\t' if file.length < 8
console.log "#level_name\t#compiler\t#file\t", ...msgs
true
/**
* Tests whether this compiler applies to the request
*
* @param {String} reqpath Request path.
* @returns {String|String[]|false-y} Resolved source path(s) if compiler matches, false otherwise.
*/
matches: (srcDir, pathname) ->
@log LOG.DEBUG, "matches(#srcDir, #pathname)"
path.join srcDir, pathname.replace(@match, @ext) if @match.exec(pathname)
srcValid: (src, cb) ->
fs.stat src, cb
validate: (pairs, cb) ->
@log LOG.DEBUG, "validate( [#pairs], #{typeof cb} )"
unless pairs? and pairs.length
return cb "No matching sources."
[srcs, destDir] = pairs.shift()
if typeof srcs is 'string'
srcs = [srcs]
src = srcs.shift()
pairs.unshift [srcs, destDir] if srcs.length
@srcValid src, (err, srcStat) ~>
if err or not srcStat
@validate pairs, cb
else
cb null, srcStat, src, destDir
/**
* Resolves request path into destination path.
*
* @param {String} src Source filepath, as calculated by `matches()`.
* @returns {String} Resolved dest path.
*/
lookup: (src, destDir, pathname) ->
path.join destDir, if @destExt then pathname.replace(@match, @destExt) else pathname
destValid: (dest, cb) ->
@log LOG.DEBUG, "destValid( #dest, #{typeof cb} )"
fs.stat dest, (err, destStat) ~>
if err and 'ENOENT' is err.code
cb null, null
else
cb err, destStat
stale: (srcStat, destStat, cb) ->
delta = (@info.delta ? @delta ? 0s) * 1000_ms # seconds -> ms
@log LOG.DEBUG, "stale( #{typeof srcStat}, #{typeof destStat}, #{typeof cb} )"
if not srcStat
cb new Error "Source does not exist?!"
else if not destStat
cb null, true
else if @info.expires? and (destStat.ctime.getTime() + @info.expires) > Date.now()
fs.unlink dest, (err) ->
if err then cb err else cb null, true
else if srcStat.mtime.getTime() > (destStat.mtime.getTime() + delta)
cb null, true
else
cb null, false
read: (src, cb) ->
fs.readFile src, 'utf8', cb
# Async compile func (if not function, used as lookup key on module)
# compile: (text, cb) -> ...
compile: null
# Synchronous compile function (if not function, used as lookup key on module)
# compileSync: (text) -> ...
compileSync: null
doCompile: (text, wrapped, cb) ->
[cb, wrapped] = [wrapped, false] unless cb
if @wraps and not wrapped
WrappedCompiler = compilers[@wraps]
wc = new WrappedCompiler(@info)
return wc.doCompile text, false, (err, data) ~>
if err then cb(err) else @doCompile data, true, cb
info_opts = @info.options?[@id] or {}
if typeof @options is 'function'
opts = @options(info_opts)
else if @options or info_opts
opts = {} <<< @options <<< info_opts
args = [text].concat(if opts? then [opts] else [])
if fn = @compile
fn = @module[fn] if typeof fn is not 'function'
fn.apply this, args.concat [cb]
else if fn = @compileSync
fn = @module[fn] if typeof fn is not 'function'
try
cb null, fn.apply this, args
catch err
cb err
else
cb new Error 'No compile function defined!?'
write: (dest, data, cb) ->
if @info.log_level <= LOG.INFO
_dest = dest
{src, cwd} = @info
cwd += '/' unless cwd[cwd.length-1] is '/'
if src.indexOf(cwd) is 0
src .= slice cwd.length
if _dest.indexOf(cwd) is 0
_dest .= slice cwd.length
prefix = commonPath src, _dest
if prefix[0] is '/'
prefix .= slice 1
if len = prefix.length
@log LOG.INFO, "writing #prefix{ #{src.slice len} --> #{_dest.slice len} }"
else
@log LOG.INFO, "writing #src --> #_dest"
fs.writeFile dest, data, 'utf8', cb
@run = (info, next) ->
Cls = this
c = info.instance = new Cls info
c.log LOG.DEBUG, 'run()'
do
Seq()
.seq ->
c.log LOG.DEBUG, 'roots:', info.roots
pairs = ( [that, destDir] if c.matches(srcDir, info.path) for [srcDir, destDir] of info.roots )
c.log LOG.DEBUG, 'pairs:', pairs
c.validate pairs, this
.seq (info.srcStat, info.src, info.destDir) ->
c.log LOG.DEBUG, 'validated src! srcStat:', (if srcStat? then (srcStat..).name else srcStat), 'src:', src, 'destDir:', destDir
info.dest = c.lookup src, destDir, info.path
if info.create_dirs
mkdirp path.dirname(info.dest), 8r0755, this
else
@ok()
.seq ->
c.destValid info.dest, this
.seq (info.destStat) ->
c.stale info.srcStat, destStat, this
.seq (info.isStale) ->
if isStale
c.read info.src, this
else
this 'Source not out of date.'
.seq (text) -> c.doCompile text, this
.seq (data) -> c.write info.dest, data, this
.seq ->
c.log LOG.DEBUG, 'Success!'
next null, true
.catch (err) ->
@die()
if err instanceof Error
c.log LOG.ERROR, 'Error:', (err.stack or err.message or err.msg or err)
next err
else
c.log LOG.DEBUG, err
next null, false
return c
@extended = (Subclass) ->
Superclass = this
Subclass.run = Superclass.run
Subclass.extended = Superclass.extended
toString: -> @id
/**
* Executes a shell command, piping the text through stdin, and capturing stdout.
*/
class exports.ExternalCompiler extends Compiler
id : 'external'
env : null # Process environment to use. Default: `process.env`.
cwd : null # Current working directory for command. Default: process.cwd().
timeout : 3.000_s # Command timeout (seconds). Default:
cmd : null # The compiler command.
preprocess : null # Optional function to peprocess the command: (cmd, text, options) -> new_cmd
-> super ...
compile : (text, options, cb) ->
unless cb
cb = options; options = {}
info_options = @info.options?[@id] or {}
options = {} <<< info_options <<< options or {}
options.timeout = (options.external_timeout ? options.timeout ? @info.external_timeout ? @timeout) * 1000_ms
options.cwd or= @cwd
options.env or= @env
cmd = if @preprocess then @preprocess @cmd, text, options else @cmd
@log LOG.DEBUG, "#cmd"
child = exec cmd, options,
(err, stdout, stderr) ~>
if err
cb new Error "#this error:\n#err"
else
cb null, String stdout
child.stderr.on 'data', (data) ~> @log LOG.WARN, "\n#data"
child.stdin.write text
child.stdin.end()
### JavaScript Compilers
class exports.CoffeeScriptCompiler extends Compiler
id : 'coffee'
ext : '.coffee'
destExt : '.js'
module : 'coffee-script'
options : { bare:true }
compileSync : 'compile'
-> super ...
class exports.SnocketsCompiler extends Compiler
id : 'snockets'
ext : '.coffee'
destExt : '.js'
module : 'snockets'
options : { async:false }
-> super ...
compileSync : (text, options={}) ->
snockets = new @module
snockets.getConcatenation @info.src, options
class exports.CocoCompiler extends Compiler
id : 'coco'
ext : '.co'
destExt : '.js'
module : 'coco'
options : { bare:true }
compileSync : 'compile'
-> super ...
class exports.LiveScriptCompiler extends Compiler
id : 'livescript'
ext : '.ls'
destExt : '.js'
module : 'LiveScript'
options : { bare:true }
compileSync : 'compile'
-> super ...
class exports.UglifyCompiler extends Compiler
id : 'uglify'
match : /\.min(\.mod)?\.js$/i,
ext : '$1.js'
module : 'uglify-js'
-> super ...
compileSync: (text) ->
ast = @module.parser.parse text # parse code and get the initial AST
ast = @module.uglify.ast_mangle ast # get a new AST with mangled names
ast = @module.uglify.ast_squeeze ast # get an AST with compression optimizations
@module.uglify.gen_code ast # get compressed code
### HTML Compilers
class exports.JadeCompiler extends Compiler
id : 'jade'
match : /\.html?$/i
ext : '.jade'
module : 'jade'
options : (opts={}) -> { +pretty, filename:@info.src } <<< opts
compile : 'render'
-> super ...
class exports.JadeBrowserPrecompiler extends Compiler
id : 'jade-browser'
match : /\.jade(?:\.mod)?(\.min)?\.js$/i
ext : '.jade'
destExt : '.jade.js'
module : 'jade'
options : (opts={}) ->
# Turning on options.compileDebug is +40% (bytes) to the templater function
{ +pretty, +client, -compileDebug, filename:@info.src } <<< opts
-> super ...
compileSync : (text, options={}) ->
template_fn = @module.compile text, options
# Convert "anonymous" named function statement to function expression
template = String template_fn .replace /^function anonymous\(/, 'function \('
"""
var template = #template;
if (typeof module != 'undefined') {
module.exports = exports = template;
}
"""
class exports.HandlebarsCompiler extends Compiler
id : 'handlebars'
match : /\.html?$/i
ext : '.handlebars'
module : 'handlebars'
options : (opts={}) -> { filename:@info.src, data:{} } <<< opts
-> super ...
compileSync: (text, options={}) ->
template = @module.compile text, options
template(options.data or {})
class exports.HandlebarsBrowserPrecompiler extends Compiler
id : 'handlebars-browser'
match : /\.handlebars(?:\.mod)?(\.min)?\.js$/i
ext : '.handlebars'
destExt : '.handlebars.js'
module : 'handlebars'
options : (opts={}) -> { filename:@info.src } <<< opts
-> super ...
compileSync: (text, options={}) ->
template_fn = @module.precompile text, options
# Convert "anonymous" named function statement to function expression
template = String template_fn .replace /^function anonymous\(/, 'function \('
"""
var template = #template;
if (typeof module != 'undefined') {
module.exports = exports = template;
}
"""
### CSS Compilers
class exports.StylusCompiler extends Compiler
id : 'stylus'
match : /\.css$/i
ext : '.styl'
module : 'stylus'
-> super ...
compile : (text, options={}, cb) ->
unless cb
[cb, options] = [options, {}]
stylus = @module(text)
options.filename = @info.src
for k, v in options
if k is 'nib' and v
@nib = require 'nib' unless @nib
stylus.use @nib()
else if <[ use import include ]>.indexOf(k) is not -1
stylus[k](v)
else
stylus.set k, v
stylus.render cb
class exports.LessCompiler extends Compiler
id : 'less'
match : /\.css$/i
ext : '.less'
module : 'less'
compile : 'render'
-> super ...
class exports.SassCompiler extends Compiler
id : 'sass'
match : /\.css$/i
ext : '.sass'
module : 'sass'
compileSync : 'render'
-> super ...
class exports.SassRubyCompiler extends ExternalCompiler
id : 'sass_ruby'
match : /\.css$/i
ext : '.sass'
cmd : 'sass --stdin --no-cache '
-> super ...
preprocess: (cmd) ->
cmd += " --load-path='#{path.dirname(@info.src)}'"
cmd + (if @info.options.sass_ruby.load_path then " --load-path='#that'" else '')
### Misc Compilers
class exports.JisonCompiler extends Compiler
id : 'jison'
ext : '.jison'
module : 'jison'
-> super ...
compileSync : (text) ->
parser = new @module.Parser text
parser.generate()
class exports.YamlCompiler extends Compiler
id : 'yaml'
match : /\.json$/i
ext : '.yaml'
module : 'js-yaml'
compileSync : (data) ->
JSON.stringify @module.load data
-> super ...
# Register Compilers
[ CoffeeScriptCompiler
CocoCompiler
LiveScriptCompiler
UglifyCompiler
JadeCompiler
JadeBrowserPrecompiler
HandlebarsCompiler
HandlebarsBrowserPrecompiler
StylusCompiler
LessCompiler
SassCompiler
JisonCompiler
SassRubyCompiler
YamlCompiler
SnocketsCompiler
].map register
### Helpers
helpers = exports.helpers = {}
helpers.expand = expand = (...parts) ->
p = path.normalize path.join ...parts
if p.indexOf('~') is 0
home = process.env.HOME or process.env.HOMEPATH
p = path.join home, p.slice(1)
path.resolve p
# Finds the longest common prefix of some number of Indexables (Arrays or Strings).
# An Indexable is anything which:
# - supports indexed lookup like `list[0]`
# - has a numeric `length` property
# - has methods `.sort()`, `.slice(start[, stop])`
helpers.extrema = extrema = (its) ->
return [] if not its?.length
return [its[0], its[0]] if its.length < 2
by_length = ( [it.length, it] for it of its ).sort()
return [ by_length[0][1], by_length[*-1][1] ]
helpers.commonPrefix = commonPrefix = (...lists) ->
return '' if not lists?.length
return lists[0] if lists.length < 2
[ shortest, longest ] = extrema(lists)
if shortest is longest
return longest
if shortest.length is 0 or longest.length is 0
return ''
for c, i of shortest
return shortest.slice(0, i) if c != longest[i]
return shortest
helpers.commonPath = commonPath = (...paths) ->
return '' if not paths?.length
return paths[0] if paths.length < 2
[ shortest, longest ] = extrema(paths)
prefix = commonPrefix(...paths)
# ...now ensure it's a full component
# while prefix.charAt(prefix.length-1) is '/'
# prefix = prefix.slice 0, -1
components = commonPrefix prefix.split('/'), longest.split('/')
components.push('') if prefix.length > components.length
return components.join('/')
helpers.mkdirp = mkdirp = function mkdirp (p, mode=8r0755, cb)
[cb, mode] = [mode, 8r0755] if typeof mode is 'function'
cb or= (->)
p = expand(p)
exists <- path.exists p
return cb null if exists
ps = p.split('/')
_p = ps.slice(0, -1).join('/')
err <- mkdirp _p, mode
return cb null if err?.code is 'EEXIST'
return cb err if err
err <- fs.mkdir p, mode
if err?.code is 'EEXIST' then cb null else cb err