#!/usr/bin/env node var Emitter = require('events').EventEmitter, forEach = require('async-foreach').forEach, Gaze = require('gaze'), meow = require('meow'), util = require('util'), path = require('path'), glob = require('glob'), sass = require('../lib'), render = require('../lib/render'), watcher = require('../lib/watcher'), stdout = require('stdout-stream'), stdin = require('get-stdin'), fs = require('fs'); /** * Initialize CLI */ var cli = meow(` Usage: node-sass [options] cat | node-sass [options] > output.css Example: Compile foobar.scss to foobar.css node-sass --output-style compressed foobar.scss > foobar.css cat foobar.scss | node-sass --output-style compressed > foobar.css Example: Watch the sass directory for changes, compile with sourcemaps to the css directory node-sass --watch --recursive --output css --source-map true --source-map-contents sass Options -w, --watch Watch a directory or file -r, --recursive Recursively watch directories or files -o, --output Output directory -x, --omit-source-map-url Omit source map URL comment from output -i, --indented-syntax Treat data from stdin as sass code (versus scss) -q, --quiet Suppress log output except on error -v, --version Prints version info --output-style CSS output style (nested | expanded | compact | compressed) --indent-type Indent type for output CSS (space | tab) --indent-width Indent width; number of spaces or tabs (maximum value: 10) --linefeed Linefeed style (cr | crlf | lf | lfcr) --source-comments Include debug info in output --source-map Emit source map (boolean, or path to output .map file) --source-map-contents Embed include contents in map --source-map-embed Embed sourceMappingUrl as data URI --source-map-root Base path, will be emitted in source-map as is --include-path Path to look for imported files --follow Follow symlinked directories --precision The amount of precision allowed in decimal numbers --error-bell Output a bell character on errors --importer Path to .js file containing custom importer --functions Path to .js file containing custom functions --help Print usage info `, { version: sass.info, flags: { errorBell: { type: 'boolean', }, functions: { type: 'string', }, follow: { type: 'boolean', }, importer: { type: 'string', }, includePath: { type: 'string', default: [process.cwd()], isMultiple: true, }, indentType: { type: 'string', default: 'space', }, indentWidth: { type: 'number', default: 2, }, indentedSyntax: { type: 'boolean', alias: 'i', }, linefeed: { type: 'string', default: 'lf', }, omitSourceMapUrl: { type: 'boolean', alias: 'x', }, output: { type: 'string', alias: 'o', }, outputStyle: { type: 'string', default: 'nested', }, precision: { type: 'number', default: 5, }, quiet: { type: 'boolean', default: false, alias: 'q', }, recursive: { type: 'boolean', default: true, alias: 'r', }, sourceMapContents: { type: 'boolean', }, sourceMapEmbed: { type: 'boolean', }, sourceMapRoot: { type: 'string', }, sourceComments: { type: 'boolean', alias: 'c', }, version: { type: 'boolean', alias: 'v', }, watch: { type: 'boolean', alias: 'w', }, }, }); /** * Is a Directory * * @param {String} filePath * @returns {Boolean} * @api private */ function isDirectory(filePath) { var isDir = false; try { var absolutePath = path.resolve(filePath); isDir = fs.statSync(absolutePath).isDirectory(); } catch (e) { isDir = e.code === 'ENOENT'; } return isDir; } /** * Get correct glob pattern * * @param {Object} options * @returns {String} * @api private */ function globPattern(options) { return options.recursive ? '**/*.{sass,scss}' : '*.{sass,scss}'; } /** * Create emitter * * @api private */ function getEmitter() { var emitter = new Emitter(); emitter.on('error', function(err) { if (options.errorBell) { err += '\x07'; } console.error(err); if (!options.watch) { process.exit(1); } }); emitter.on('warn', function(data) { if (!options.quiet) { console.warn(data); } }); emitter.on('info', function(data) { if (!options.quiet) { console.info(data); } }); emitter.on('log', stdout.write.bind(stdout)); return emitter; } /** * Construct options * * @param {Array} arguments * @param {Object} options * @api private */ function getOptions(args, options) { var cssDir, sassDir, file, mapDir; options.src = args[0]; if (args[1]) { options.dest = path.resolve(args[1]); } else if (options.output) { options.dest = path.join( path.resolve(options.output), [path.basename(options.src, path.extname(options.src)), '.css'].join('')); // replace ext. } if (options.directory) { sassDir = path.resolve(options.directory); file = path.relative(sassDir, args[0]); cssDir = path.resolve(options.output); options.dest = path.join(cssDir, file).replace(path.extname(file), '.css'); } if (options.sourceMap) { if(!options.sourceMapOriginal) { options.sourceMapOriginal = options.sourceMap; } if (options.sourceMapOriginal === 'true') { options.sourceMap = options.dest + '.map'; } else { // check if sourceMap path ends with .map to avoid isDirectory false-positive var sourceMapIsDirectory = options.sourceMapOriginal.indexOf('.map', options.sourceMapOriginal.length - 4) === -1 && isDirectory(options.sourceMapOriginal); if (!sourceMapIsDirectory) { options.sourceMap = path.resolve(options.sourceMapOriginal); } else if (!options.directory) { options.sourceMap = path.resolve(options.sourceMapOriginal, path.basename(options.dest) + '.map'); } else { sassDir = path.resolve(options.directory); file = path.relative(sassDir, args[0]); mapDir = path.resolve(options.sourceMapOriginal); options.sourceMap = path.join(mapDir, file).replace(path.extname(file), '.css.map'); } } } return options; } /** * Watch * * @param {Object} options * @param {Object} emitter * @api private */ function watch(options, emitter) { var handler = function(files) { files.added.forEach(function(file) { var watch = gaze.watched(); Object.keys(watch).forEach(function (dir) { if (watch[dir].indexOf(file) !== -1) { gaze.add(file); } }); }); files.changed.forEach(function(file) { if (path.basename(file)[0] !== '_') { renderFile(file, options, emitter); } }); files.removed.forEach(function(file) { gaze.remove(file); }); }; var gaze = new Gaze(); gaze.add(watcher.reset(options)); gaze.on('error', emitter.emit.bind(emitter, 'error')); gaze.on('changed', function(file) { handler(watcher.changed(file)); }); gaze.on('added', function(file) { handler(watcher.added(file)); }); gaze.on('deleted', function(file) { handler(watcher.removed(file)); }); } /** * Run * * @param {Object} options * @param {Object} emitter * @api private */ function run(options, emitter) { if (options.directory) { if (!options.output) { emitter.emit('error', 'An output directory must be specified when compiling a directory'); } if (!isDirectory(options.output)) { emitter.emit('error', 'An output directory must be specified when compiling a directory'); } } if (options.sourceMapOriginal && options.directory && !isDirectory(options.sourceMapOriginal) && options.sourceMapOriginal !== 'true') { emitter.emit('error', 'The --source-map option must be either a boolean or directory when compiling a directory'); } if (options.importer) { if ((path.resolve(options.importer) === path.normalize(options.importer).replace(/(.+)([/|\\])$/, '$1'))) { options.importer = require(options.importer); } else { options.importer = require(path.resolve(options.importer)); } } if (options.functions) { if ((path.resolve(options.functions) === path.normalize(options.functions).replace(/(.+)([/|\\])$/, '$1'))) { options.functions = require(options.functions); } else { options.functions = require(path.resolve(options.functions)); } } if (options.watch) { watch(options, emitter); } else if (options.directory) { renderDir(options, emitter); } else { render(options, emitter); } } /** * Render a file * * @param {String} file * @param {Object} options * @param {Object} emitter * @api private */ function renderFile(file, options, emitter) { options = getOptions([path.resolve(file)], options); if (options.watch && !options.quiet) { emitter.emit('info', util.format('=> changed: %s', file)); } render(options, emitter); } /** * Render all sass files in a directory * * @param {Object} options * @param {Object} emitter * @api private */ function renderDir(options, emitter) { var globPath = path.resolve(options.directory, globPattern(options)); glob(globPath, { ignore: '**/_*', follow: options.follow }, function(err, files) { if (err) { return emitter.emit('error', util.format('You do not have permission to access this path: %s.', err.path)); } else if (!files.length) { return emitter.emit('error', 'No input file was found.'); } forEach(files, function(subject) { emitter.once('done', this.async()); renderFile(subject, options, emitter); }, function(successful, arr) { var outputDir = path.join(process.cwd(), options.output); if (!options.quiet) { emitter.emit('info', util.format('Wrote %s CSS files to %s', arr.length, outputDir)); } process.exit(); }); }); } /** * Arguments and options */ var options = getOptions(cli.input, cli.flags); var emitter = getEmitter(); /** * Show usage if no arguments are supplied */ if (!options.src && process.stdin.isTTY) { emitter.emit('error', [ 'Provide a Sass file to render', '', 'Example: Compile foobar.scss to foobar.css', ' node-sass --output-style compressed foobar.scss > foobar.css', ' cat foobar.scss | node-sass --output-style compressed > foobar.css', '', 'Example: Watch the sass directory for changes, compile with sourcemaps to the css directory', ' node-sass --watch --recursive --output css', ' --source-map true --source-map-contents sass', ].join('\n')); } /** * Apply arguments */ if (options.src) { if (isDirectory(options.src)) { options.directory = options.src; } run(options, emitter); } else if (!process.stdin.isTTY) { stdin(function(data) { options.data = data; options.stdin = true; run(options, emitter); }); }