Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Tree: 60a163de41
Fetching contributors…

Cannot retrieve contributors at this time

614 lines (471 sloc) 19.978 kB
fs = require 'fs'
path = require 'path'
parse = require('url').parse
child_process = require 'child_process'
exec = child_process.exec
spawn = child_process.spawn
EventEmitter = require('events').EventEmitter
Seq = require 'seq'
/**
* Sets up an instance of the CompilerMiddleware.
*
* @param {Object} [settings={}]
* @param {Compiler[]} [...custom]
* @returns {Function}
*/
exports = module.exports = CompilerMiddleware = (settings={}, ...custom) ->
CWD = process.cwd()
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 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 )
settings.log_level = LOG.stringToLevel settings.log_level
if settings.log_level <= LOG.DEBUG
console.log 'compiler.setup()'
console.dir settings
console.log ''
return (req, res, next) ->
if settings.allowed_methods.indexOf(req.method) is -1
return next()
request =
req : req
res : res
next : next
url : req.url
path : parse(req.url).pathname
if settings.mount and request.path.indexOf(settings.mount) is 0
request.path.=slice(settings.mount.length)
info = {} <<< settings <<< request <<<
settings : settings
request : request
cwd : 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 do
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
/** All known compilers, by id. */
exports.compilers = compilers = {}
/** 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
/** 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.
resolve_index : false # if true-y, resolve directories to this filename: false | true => 'index.html' | str
allowed_methods : [ 'GET' ] # HTTP methods compiler should process
# TODO
# 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)
/**
* 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 : /\.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`).
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 ''
name = String(this)
name += '\t' if name.length < 8
console.log "\t#{level_name}\t#{name}\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, 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
opts = if typeof @options is 'function' then @options() else @options
args = [text] + (if opts? then [opts] else [])
if fn = @compile
fn = @module[fn] if typeof fn is not 'function'
fn.apply this, args + [cb]
else if fn = @compileSync
fn = @module[fn] if typeof fn is not 'function'
cb null, fn.apply this, args
else
cb new Error 'No compile function defined!?'
write: (dest, data, cb) ->
@log LOG.INFO, "writing #{@info.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
this.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.INFO, '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.INFO, err
next null, false
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.INFO, "#{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.INFO, data
child.stdin.write text
child.stdin.end()
### JavaScript Compilers
class exports.CoffeeScriptCompiler extends Compiler
id : 'coffee'
ext : '.coffee'
module : 'coffee-script'
options : { bare:true }
compileSync : 'compile'
-> super ...
class exports.CocoCompiler extends Compiler
id : 'coco'
ext : '.co'
module : 'coco'
options : { bare:true }
compileSync : 'compile'
-> super ...
class exports.CommonJSCompiler extends Compiler
CJS_HEADER : "require.install('{ID}', function(require, exports, module, undefined){\n\n"
CJS_FOOTER : "\n\n});\n"
id : 'commonjs'
match : /\.mod(\.min)?\.js$/i
ext : [ '.co', '.coffee', '.jison', '.js' ]
ext2wraps:
co : 'coco'
coffee : 'coffee'
jison : 'jison'
drop_path_parts : 0 # allows you to drop the mountpoint if your js files live in, say, /js
-> super ...
matches: (srcDir, pathname) ->
src = path.join srcDir, pathname
( src.replace(@match, ext) for ext of @ext ) if @match.exec(pathname)
# trim off .min if present
lookup: (src, destDir, pathname) ->
path.join(destDir, pathname).replace(@match, '.mod.js')
compileSync: (data) ->
drop = @info.drop_path_parts ? @drop_path_parts
mod_parts = @info.url.slice(1).replace(/\.mod(\.min)?\.js$/i, '').split('/').slice(drop)
mod_parts.pop() if mod_parts[mod_parts.length] is 'index'
mod_id = path.normalize mod_parts.join('/')
header = @CJS_HEADER.replace '{ID}', mod_id
header+data+@CJS_FOOTER
doCompile: (text, wrapped, cb) ->
ext = /\.([^\.]+)$/.exec(@info.src)[1]
@wraps = that if @ext2wraps[ext]
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 : -> { pretty:true, filename:@info.src }
compileSync : 'compile'
-> super ...
### CSS Compilers
class exports.LessCompiler extends Compiler
id : 'less'
match : /\.css$/i
ext : '.less'
module : 'less'
compile : 'render'
-> super ...
class exports.SassJSCompiler extends Compiler
id : 'sass_js'
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 '')
# XXX: compress CSS with ?
### 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 ExternalCompiler
id : 'yaml'
match : /\.json$/i
ext : '.yaml'
cmd : "python -c 'import lessly.data.yaml_omap; import sys, yaml, json; json.dump(yaml.load(sys.stdin), sys.stdout)'"
-> super ...
cs =
CoffeeScriptCompiler
CocoCompiler
CommonJSCompiler
UglifyCompiler
JadeCompiler
LessCompiler
SassJSCompiler
SassRubyCompiler
JisonCompiler
YamlCompiler
for c of cs then register c
### Helpers
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
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
Jump to Line
Something went wrong with that request. Please try again.