assets.coffee | |
|---|---|
connectCache = require 'connect-file-cache'
Snockets = require 'snockets'
crypto = require 'crypto'
fs = require 'fs'
path = require 'path'
{parse} = require 'url'
libs = {}
jsCompilers = Snockets.compilers
extend = (dest, objs...) ->
for obj in objs
dest[k] = v for k, v of obj
dest
module.exports = exports = (options = {}) ->
return connectAssets if connectAssets
options.src ?= 'assets'
options.helperContext ?= global
if process.env.NODE_ENV is 'production'
options.build ?= true
cssCompilers.styl.compress ?= true
cssCompilers.less.compress ?= true
options.servePath ?= ''
options.buildDir ?= 'builtAssets'
options.buildFilenamer ?= md5Filenamer
options.buildsExpire ?= false
options.detectChanges ?= process.env.NODE_ENV isnt 'production'
options.minifyBuilds ?= true
options.pathsOnly ?= false
libs.stylusExtends = options.stylusExtends ?= () => {};
jsCompilers = extend jsCompilers, options.jsCompilers || {}
connectAssets = module.exports.instance = new ConnectAssets options
connectAssets.createHelpers options
connectAssets.cache.middleware
class ConnectAssets
constructor: (@options) ->
@cache = connectCache()
@snockets = new Snockets src: @options.src | |
| Things that we must cache to work efficiently with CSS compilers | @cssSourceFiles = {}
@compiledCss = {} |
| Things that we must cache to efficiently use MD5 suffixes | @buildFilenames = {} |
| Things that we must cache if changes aren't detected | @cachedRoutePaths = {} |
CSS and JS tag functions | createHelpers: ->
context = @options.helperContext
srcIsRemote = @options.src.match REMOTE_PATH
expandRoute = (shortRoute, ext, rootDir) ->
rootDir = rootDir[1..] if rootDir[0] is '/'
if shortRoute.match EXPLICIT_PATH
unless shortRoute.match REMOTE_PATH
if shortRoute[0] is '/' then shortRoute = shortRoute[1..]
else
shortRoute = rootDir + '/' + shortRoute if rootDir isnt ''
if ext and shortRoute.indexOf(ext, shortRoute.length - ext.length) is -1
shortRoute += ext
shortRoute
context.css = (route) =>
route = expandRoute route, '.css', context.css.root
unless route.match REMOTE_PATH
route = @options.servePath + @compileCSS route
return route if @options.pathsOnly
'<link rel="stylesheet" href="' + route + '" />'
context.css.root = 'css'
context.js = (route, routeOptions) =>
loadingKeyword = ''
route = expandRoute route, '.js', context.js.root
if route.match REMOTE_PATH
routes = [route]
else if srcIsRemote
routes = ["#{@options.src}/#{route}"]
else
routes = (@options.servePath + p for p in @compileJS route)
return routes if @options.pathsOnly
if routeOptions? and @options.build
loadingKeyword = 'async ' if routeOptions.async?
loadingKeyword = 'defer ' if routeOptions.defer?
('<script ' + loadingKeyword + 'src="' + r + '"></script>' for r in routes).join '\n'
context.js.root = 'js'
context.img = (route) =>
route = expandRoute route, null, context.img.root
if route.match REMOTE_PATH
routes = route
else if srcIsRemote
route = "#{@options.src}/#{route}"
else
route = @options.servePath + @cacheImg route
route
context.img.root = 'img' |
| Synchronously lookup image and return the route | cacheImg: (route) ->
if !@options.detectChanges and @cachedRoutePaths[route]
return @cachedRoutePaths[route]
sourcePath = route
try
stats = fs.statSync @absPath(sourcePath)
if timeEq mtime, @cache.map[route]?.mtime
alreadyCached = true
else
{mtime} = stats
img = fs.readFileSync @absPath(sourcePath)
if alreadyCached and @options.build
filename = @buildFilenames[sourcePath]
return "/#{filename}"
else if alreadyCached
return "/#{route}"
else if @options.build
filename = @options.buildFilenamer(route, getExt route)
@buildFilenames[sourcePath] = filename
cacheFlags = {expires: @options.buildsExpire, mtime}
@cache.set filename, img, cacheFlags
if @options.buildDir
buildPath = path.join process.cwd(), @options.buildDir, filename
mkdirRecursive path.dirname(buildPath), 0o0755, ->
fs.writeFile buildPath, img
return @cachedRoutePaths[route] = "/#{filename}"
else
@cache.set route, img, {mtime}
return @cachedRoutePaths[route] = "/#{route}"
catch e
''
throw new Error("No file found for route #{route}")
resolveImgPath: (path) ->
resolvedPath = path + ""
resolvedPath = resolvedPath.replace /url\(|'|"|\)/g, ''
try
resolvedPath = @options.helperContext.img resolvedPath
catch e
console.error "Can't resolve image path: #{resolvedPath}"
return "url('#{resolvedPath}')"
fixCSSImagePaths: (css) ->
regex = /url\([^\)]+\)/g
css = css.replace regex, @resolveImgPath.bind(@)
return css |
| Synchronously compile Stylus to CSS (if needed) and return the route | compileCSS: (route) ->
if !@options.detectChanges and @cachedRoutePaths[route]
return @cachedRoutePaths[route]
for ext in ['css'].concat (ext for ext of cssCompilers)
sourcePath = stripExt(route) + ".#{ext}"
try
stats = fs.statSync @absPath(sourcePath)
if ext is 'css'
if timeEq mtime, @cache.map[route]?.mtime
alreadyCached = true
else
{mtime} = stats
css = (fs.readFileSync @absPath(sourcePath)).toString 'utf8'
css = @fixCSSImagePaths css
else
if timeEq stats.mtime, @cssSourceFiles[sourcePath]?.mtime
source = @cssSourceFiles[sourcePath].data.toString 'utf8'
else
data = fs.readFileSync @absPath(sourcePath)
@cssSourceFiles[sourcePath] = {data, mtime: stats.mtime}
source = data.toString 'utf8'
startTime = new Date
css = cssCompilers[ext].compileSync @absPath(sourcePath), source
if css is @compiledCss[sourcePath]?.data.toString 'utf8'
alreadyCached = true
else
mtime = new Date
@compiledCss[sourcePath] = {data: new Buffer(css), mtime}
if alreadyCached and @options.build
filename = @buildFilenames[sourcePath]
return "/#{filename}"
else if alreadyCached
return "/#{route}"
else if @options.build
filename = @options.buildFilenamer route, css
@buildFilenames[sourcePath] = filename
cacheFlags = {expires: @options.buildsExpire, mtime}
@cache.set filename, css, cacheFlags
if @options.buildDir
buildPath = path.join process.cwd(), @options.buildDir, filename
mkdirRecursive path.dirname(buildPath), 0o0755, ->
fs.writeFile buildPath, css
return @cachedRoutePaths[route] = "/#{filename}"
else
@cache.set route, css, {mtime}
return @cachedRoutePaths[route] = "/#{route}"
catch e
if e.code is 'ENOENT' then continue else throw e
throw new Error("No file found for route #{route}") |
| Synchronously compile to JS with Snockets (if needed) and return route(s) | compileJS: (route) ->
if !@options.detectChanges and @cachedRoutePaths[route]
return @cachedRoutePaths[route]
for ext in ['js'].concat (ext for ext of jsCompilers)
sourcePath = stripExt(route) + ".#{ext}"
try
if @options.build
filename = null
callback = (err, concatenation, changed) =>
throw err if err
if changed
filename = @options.buildFilenamer route, concatenation
@buildFilenames[sourcePath] = filename
cacheFlags = expires: @options.buildsExpire
@cache.set filename, concatenation, cacheFlags
if buildDir = @options.buildDir
buildPath = path.join process.cwd(), buildDir, filename
mkdirRecursive path.dirname(buildPath), 0o0755, (err) ->
fs.writeFile buildPath, concatenation
else
filename = @buildFilenames[sourcePath]
snocketsFlags = minify: @options.minifyBuilds, async: false
@snockets.getConcatenation sourcePath, snocketsFlags, callback
return @cachedRoutePaths[route] = ["/#{filename}"]
else
chain = @snockets.getCompiledChain sourcePath, async: false
return @cachedRoutePaths[route] = for {filename, js} in chain
filename = stripExt(filename) + '.js'
@cache.set filename, js
"/#{filename}"
catch e
if e.code is 'ENOENT' then continue else throw e
throw new Error("No file found for route #{route}")
absPath: (route) ->
if @options.src.match EXPLICIT_PATH
path.join @options.src, route
else
path.join process.cwd(), @options.src, route |
Asset compilers | exports.cssCompilers = cssCompilers =
styl:
optionsMap: {}
compileSync: (sourcePath, source) ->
result = ''
callback = (err, js) ->
throw err if err
result = js
libs.stylus or= require 'stylus'
libs.bootstrap or= try require 'bootstrap-stylus' catch e then (-> ->)
libs.nib or= try require 'nib' catch e then (-> ->)
libs.bootstrap or= try require 'bootstrap-stylus' catch e then (-> ->)
options = @optionsMap[sourcePath] ?=
filename: sourcePath
libs.stylus(source, options)
.use(libs.bootstrap())
.use(libs.nib())
.use(libs.bootstrap())
.use(libs.stylusExtends)
.set('compress', @compress)
.set('include css', true)
.render callback
result
less:
optionsMap:
optimization: 1
silent: false
paths: []
color: true
patchLess: (less) -> |
| Monkey patch importer of LESS to load files synchronously | less.Parser.importer = (file, paths, callback) ->
paths.unshift "."
i = 0
while i < paths.length
try
pathname = path.join(paths[i], file)
fs.statSync(pathname)
break
catch e
pathname = null
i++
if not pathname
throw new Error "File #{file} not found"
data = fs.readFileSync(pathname, 'utf-8')
new(less.Parser)(
paths: [path.dirname(pathname)].concat(paths)
filename: pathname
).parse(data, (e, root) ->
if (e)
less.writeError(e)
callback(e, root)
)
less
compileSync: (sourcePath, source) ->
result = ""
libs.less or= @patchLess (require 'less')
options = @optionsMap
options.filename = sourcePath
options.paths = [path.dirname(sourcePath)].concat(options.paths)
compress = @compress ? false
callback = (err, tree) ->
throw err if err
result = tree.toCSS({compress: compress})
new libs.less.Parser(options).parse(source, callback)
result
exports.jsCompilers = jsCompilers |
Regexes | BEFORE_DOT = /([^.]*)(\..*)?$/
EXPLICIT_PATH = /^\/|\/\/|\w:/
REMOTE_PATH = /\/\// |
Utility functions | getExt = (filePath) ->
if(lastDotIndex = filePath.lastIndexOf '.') >= 0
filePath[(lastDotIndex + 1)...]
''
stripExt = (filePath) ->
if (lastDotIndex = filePath.lastIndexOf '.') >= 0
filePath[0...lastDotIndex]
else
filePath
cssExts = ->
(".#{ext}" for ext of cssCompilers).concat '.css'
timeEq = (date1, date2) ->
date1? and date2? and date1.getTime() is date2.getTime()
mkdirRecursive = (dir, mode, callback) ->
pathParts = path.normalize(dir).split path.sep
if fs.existsSync dir
return callback null
mkdirRecursive pathParts.slice(0,-1).join(path.sep), mode, (err) ->
return callback err if err and err.errno isnt process.EEXIST
fs.mkdirSync dir, mode
callback()
exports.md5Filenamer = md5Filenamer = (filename, code) ->
hash = crypto.createHash('md5')
hash.update code
md5Hex = hash.digest 'hex'
ext = path.extname filename
"#{stripExt filename}-#{md5Hex}#{ext}"
|